Step 5 of 6
SHOP-240: the order workflow state machine
SHOP-240: Enforce the order lifecycle
Orders have been shipped without payment and refunded after delivery, because any code could set
order.status = "shipped". Model the workflow so that only legal transitions are possible, and every illegal one gets a clear error.
An enum state machine (module 42) makes illegal states unrepresentable: each state is a variant that carries exactly the data that exists in that state (a tracking number only once shipped), and one function decides every transition. Matching on the (state, event) tuple lists the legal moves explicitly, and a final catch-all arm rejects everything else:
#[derive(Debug)]
enum Door {
Open,
Closed,
Locked { code: u32 },
}
#[derive(Debug)]
enum Action {
Close,
Lock(u32),
Unlock(u32),
Open,
}
fn step(door: Door, action: &Action) -> Result<Door, (Door, String)> {
match (door, action) {
(Door::Open, Action::Close) => Ok(Door::Closed),
(Door::Closed, Action::Open) => Ok(Door::Open),
(Door::Closed, Action::Lock(code)) => Ok(Door::Locked { code: *code }),
(Door::Locked { code }, Action::Unlock(attempt)) if *attempt == code => Ok(Door::Closed),
(door @ Door::Locked { .. }, Action::Unlock(_)) => Err((door, "wrong code".to_string())),
(door, action) => {
let why = format!("cannot {action:?} while {door:?}");
Err((door, why))
}
}
}
fn main() {
let actions = [Action::Close, Action::Lock(1234), Action::Open, Action::Unlock(1111), Action::Unlock(1234), Action::Open];
let mut door = Door::Open;
for action in &actions {
door = match step(door, action) {
Ok(next) => next,
Err((unchanged, why)) => {
println!("rejected {action:?}: {why}");
unchanged
}
};
}
println!("final: {door:?}");
}
rejected Open: cannot Open while Locked { code: 1234 }
rejected Unlock(1111): wrong code
final: Open
steptakes the state by value and returns the next one, so nobody can keep using the old state by accident.- When an action is rejected, the error hands the unchanged state back (
Err((door, why))), so the caller can carry on. That's a useful pattern for any function that consumes a value and might fail. door @ Door::Locked { .. }matches a locked door and binds the whole value todoorat the same time.
Common mistakes
- A catch-all arm too early. Arms are tried top to bottom;
(state, _) => Err(...)above the legal transitions makes them unreachable (the compiler warns about it). - Status strings plus separate optional fields (
status: String,tracking: Option<String>): nothing stops"shipped"without a tracking number. Put the data inside the variant. - Validating data in the wrong place. "Payment must be positive" belongs to the transition that takes the payment, so it's checked exactly when it matters.
Your task
Implement the order workflow. The seed defines OrderState, Event and TransitionError (with thiserror). Write:
OrderState::name():created,paid,shipped,delivered,cancelled; andEvent::name():pay,ship,deliver,cancelOrderState::describe():created,paid 25.00(the amount in cents shown with two decimals),shipped, tracking TRK123,delivered,cancelled: REASONfn apply(state: OrderState, event: Event) -> Result<OrderState, TransitionError>with these legal transitions: Created + Pay becomes Paid (a payment of 0 isZeroPayment); Paid + Ship becomes Shipped (an empty or all-spaces tracking number isEmptyTracking); Shipped + Deliver becomes Delivered; Created or Paid + Cancel becomes Cancelled. Anything else isNotAllowed { state: state.name(), event: event.name() }.
Challenge 2
SUP-118: Replay the support ticket log
The helpdesk exports every action as a line of text. We need the final state of every ticket, plus a list of actions the old system wrongly accepted.
Standard input has one action per line: TICKET ACTION [NAME]. A ticket that appears for the first time starts open. The actions and their legal transitions:
assign NAME: open becomes in progress (NAME); in progress becomes in progress with the new nameresolve: in progress becomes resolvedreopen: resolved becomes openclose: resolved becomes closed
For every illegal action, print rejected: TICKET ACTION (ticket is STATE) as you go, and leave the ticket unchanged. At the end, print every ticket in order of its id with TICKET: STATE, where STATE is open, in progress (NAME), resolved or closed. Ignore lines that don't have a known action or are missing the name for assign (they don't create a ticket either).
Model the state as an enum with a describe() method, and keep the tickets in a BTreeMap<String, TicketState>.
Challenge 3
CMS-77: Articles must be reviewed before publishing
An intern published a draft by calling
publish()directly. Make that impossible at compile time.
Use the typestate pattern (module 34): a separate type per state, where each method consumes self and returns the next state's type, so calling a method in the wrong state doesn't compile. Write:
Draft::new(title: &str) -> Draft(empty body),Draft::write(self, text: &str) -> Draft(appendstextto the body, with a space between pieces),Draft::submit(self) -> InReviewInReview::approve(self, reviewer: &str) -> PublishedandInReview::reject(self) -> Draft(back to editing, body kept)Published::headline(&self) -> StringreturningTITLE (approved by REVIEWER), andPublished::body(&self) -> &str
There's no publish on Draft at all: the only way to a Published is through review.
Previous: OPS-330: validate the deploy config Next: REVIEW-9: refactor for code review