Appearance
9.4.14 — State
What the original Gang of Four book says: Allow an object to change its behaviour when its internal state changes. The object will appear to change its class.
What that means when you are actually writing code: When a
statusfield decides what every method is allowed to do, stop writingif (status === …)at the top of every method. Turn each status into an object that knows what it permits and what comes next.
State is the pattern that turns a lifecycle from folklore into code. Almost every important object in a real system has a lifecycle: orders, payments, subscriptions, support tickets, deployments, network connections, file uploads. And in almost every codebase, that lifecycle is stored as a status string plus a scattering of if statements that nobody has ever seen assembled in one place.
This is also, quietly, the most interview-relevant page in the whole catalogue. When somebody asks you to design a vending machine, an elevator, a traffic light or an ATM, they are asking a State question wearing a costume (9.7.3, 9.7.28).
1. The story: the order status that nobody could draw
An Order has a status column in the database. Over two years, this is what happens to the class:
typescript
class Order {
status: "draft" | "placed" | "paid" | "shipped" | "delivered" | "cancelled" | "refunded";
addItem(item: Item) {
if (this.status !== "draft") throw new Error("cannot modify a placed order");
this.items.push(item);
}
pay(payment: Payment) {
if (this.status === "draft") throw new Error("place the order first");
if (this.status === "paid") throw new Error("already paid");
if (this.status === "cancelled") throw new Error("order was cancelled");
if (this.status === "shipped" || this.status === "delivered") throw new Error("already fulfilled");
// …and refunded? Nobody checked. Paying a refunded order silently succeeds.
this.status = "paid";
}
ship(carrier: Carrier) {
if (this.status !== "paid") throw new Error("cannot ship");
this.status = "shipped";
}
cancel(reason: string) {
if (this.status === "shipped") throw new Error("too late");
if (this.status === "delivered") throw new Error("too late");
if (this.status === "cancelled") return; // idempotent here…
if (this.status === "refunded") throw new Error("already refunded"); // …but not here
this.status = "cancelled";
if (this.status === "paid") this.refund(); // ← DEAD CODE: status was just overwritten
}
}That last line is not a hypothetical example invented to make a point. It is the single most common bug in hand-written state machines, and it is close to invisible during code review. The author set status to "cancelled" and then, two lines later, checked whether the status was "paid". That check can never be true. The refund never happens. No test catches it, because the branch looks perfectly reasonable when you read it on its own. The loss shows up in a finance reconciliation several weeks later, by which point nobody remembers writing the code.
Beyond that one bug, here is what has gone wrong more generally.
The state machine exists, but it does not live anywhere in particular. The transition rules have been distributed across every method as guard clauses. There is no file, no diagram and no test that shows the whole lifecycle. If a product manager asks "can a delivered order be refunded?", the only way to answer is to read seven methods and hold them all in your head simultaneously.
Every new status means editing every method. Suppose the business adds "on_hold". You now have to revisit pay, ship, cancel, addItem and every other guarded method, and you will miss one. In fact the code above already misses one, because pay guards against draft, paid, cancelled, shipped and delivered but forgets refunded entirely.
The guards are written as negations, which makes gaps invisible. A single check like if (status !== "paid") throw happens to be complete by accident. But a list of separate checks — if (a) throw; if (b) throw; if (c) throw — is a list that a human being has to keep complete from memory. The compiler cannot help, because these are just ad-hoc boolean expressions rather than anything it understands as an exhaustive set.
Illegal transitions are only discovered at runtime, and usually not as an exception. Usually they surface as corrupted data, such as an order that is somehow both shipped and cancelled, discovered when the warehouse asks why they dispatched something that customer support already refunded.
Behaviour and transition are tangled together. The cancel method both decides whether the transition is allowed and performs the side effects of it, all in one block. Neither half can be tested without the other.
The status field can be changed from anywhere. The line order.status = "paid" compiles from any file in the entire codebase. Every invariant that all those careful guard clauses were protecting can be bypassed by one assignment written by somebody who did not know the guards existed.
State fixes this by naming each status, giving it the behaviour it permits, and making the transition table a single readable thing:
typescript
order.pay(payment); // ← the CURRENT STATE decides whether this is legal and what happens next2. How you arrive at the pattern
Step 1 — Start naive, and stay there for small machines. A status field plus guard clauses is genuinely fine for two or three states with a straight-line flow, such as draft → published or pending → done. Do not build a state machine for something that is really a boolean.
Step 2 — Wait for the force. Three conditions have to be present together. The object has several modes. Each method behaves differently depending on the mode. And the modes move between each other according to rules. Two out of three is a warning sign worth watching. All three together is State.
Step 3 — Draw the line between what varies and what stays fixed.
| What varies | the behaviour of every operation, and which operations are even legal, in each mode |
| What stays fixed | the set of operations the object offers — pay, ship, cancel — and the fact that exactly one mode is active at a time |
The fixed set of operations becomes the state interface. Each mode becomes a class implementing it.
Step 4 — Decide when the choice gets made. At runtime, and crucially the object drives it itself. The object replaces its own state as events arrive.
This is the property that separates State from Strategy. The two are structurally identical, but a strategy is handed in from outside and then stays put, while a state installs its own successor.
Step 5 — Name the pattern and be clear about the costs. The name is State.
The first cost is a class per state. Five states means five files. That is heavy if the machine is trivial, which is exactly why step 1 above matters so much.
The second cost is that transition logic is spread across the state classes unless you deliberately centralise it in a table, as section 6.2 describes. Without that table, the question "show me the whole machine" requires reading every class.
The third cost is that the object has to expose enough of itself to its states for them to do their work, which creates pressure to widen its interface.
The fourth cost is that persistence needs an explicit mapping between the in-memory state object and the value stored in the database.
Against those, here is what you get. Illegal transitions become structurally impossible rather than conditionally checked. Adding a state means adding a file rather than editing seven methods. Each state can be tested completely on its own. And, the benefit that actually wins design arguments, the lifecycle becomes readable in one place.
3. The mental model
In one sentence: an object built with State does not ask "what am I?" before acting. It hands the work to whichever mode it currently is, and that mode both performs the action and decides what the object becomes next.
The analogy that makes it stick — a traffic light. Red does not consult a status variable on every timer tick to work out what it should do. Red is the behaviour "stop the traffic", combined with the knowledge "my successor is Green".
Now think about what happens when the city wants a flashing amber mode for overnight operation. You add a mode. You do not add a branch to every method. And there is a second property that makes this correct rather than merely tidy: while the light is Red, "become Green after sixty seconds" is the only transition that exists in the entire system. There is no code path anywhere that can turn Red into Blue, because Red simply never mentions Blue.
A second analogy, for the enforcement point — a door. An open door offers close() but not lock(). A closed door offers open() and lock(). A locked door offers only unlock().
In a typed language you can push this idea far enough that lockedDoor.open() fails to compile, which section 6.3 shows how to do. At that point the illegal operation is not rejected at runtime. It does not exist.
When to reach for it. The signals are consistent:
- "depending on the status, this method needs to do something different"
- a field named
status,state,phaseormodethat is read in more than two methods - nobody on the team can answer "which transitions are allowed?" without reading code
- methods that begin with a stack of
if (status === …) throwguards - the phrase "this can only happen after that"
- the words workflow, lifecycle, approval, protocol, session or connection
- and in interviews: design a vending machine, an elevator, an ATM, a traffic light, a TCP connection, a media player, or an order workflow
4. Structure
Paid permits ship, while Shipped does not permit cancel, shown in red. Because a transition can only be created by a state that names it, an illegal transition is not "checked and rejected" — there is no code path for it at all.The participants are simple. The object owns the lifecycle, holds the current state, and hands every operation to it. The State interface is the fixed set of operations. Each concrete state is one mode: it implements the operations it permits, refuses the ones it does not, and produces the successor.
Now the design decision that shapes your whole implementation: who actually performs the transition?
There are three options, and choosing deliberately is what separates a clean machine from a tangle.
The first option is that the state returns its successor and the object installs it. This is the cleanest and it is the default recommended here. States never touch the object's internal fields, and the object gets exactly one line where transitions happen, which is perfect for logging and persistence.
The second option is that the state changes the object directly, calling something like ctx.setState(new Paid()). This is the original Gang of Four spelling. It is slightly more direct, but now transitions happen in many places rather than one, and the object has to expose a setter that anything could call.
The third option is that a central transition table decides, and the states hold only behaviour, which section 6.2 covers. This is the most inspectable option and the right one when the machine is large or must be visualised and audited.
5. The code, walked through line by line
typescript
export interface OrderState { // (1) the FIXED set of operations
readonly name: OrderStatus; // persistence and logging need a tag
pay(ctx: OrderContext, payment: Payment): OrderState; // (2) returns the SUCCESSOR state
ship(ctx: OrderContext, carrier: Carrier): OrderState;
cancel(ctx: OrderContext, reason: string): OrderState;
}
abstract class BaseState implements OrderState { // (3) the default is refuse, not ignore
abstract readonly name: OrderStatus;
pay(): OrderState { throw new IllegalTransition(this.name, "pay"); }
ship(): OrderState { throw new IllegalTransition(this.name, "ship"); }
cancel(): OrderState { throw new IllegalTransition(this.name, "cancel"); }
}
class Placed extends BaseState { // (4) one mode, one class
readonly name = "placed" as const;
pay(ctx: OrderContext, payment: Payment): OrderState {
ctx.recordPayment(payment); // (5) do the work first…
return new Paid(); // …then name the successor
}
cancel(ctx: OrderContext, reason: string): OrderState {
ctx.releaseInventory();
return new Cancelled(reason);
}
// ship() is deliberately not written here, so it inherits the refusal. Absence IS the rule.
}
class Paid extends BaseState {
readonly name = "paid" as const;
ship(ctx: OrderContext, carrier: Carrier): OrderState {
ctx.createShipment(carrier);
return new Shipped(carrier);
}
cancel(ctx: OrderContext, reason: string): OrderState {
ctx.refundPayment(); // (6) cancelling a PAID order refunds —
ctx.releaseInventory(); // the branch that was dead code before
return new Cancelled(reason);
}
}
class Shipped extends BaseState {
readonly name = "shipped" as const;
// No cancel method. A shipped order cannot be cancelled; it can only be returned,
// which is a different operation with different rules and different money movement.
}
export class Order { // (7) the object that owns the lifecycle
private state: OrderState = new Draft();
pay(payment: Payment) { this.transition(this.state.pay(this, payment)); }
ship(carrier: Carrier) { this.transition(this.state.ship(this, carrier)); }
cancel(reason: string) { this.transition(this.state.cancel(this, reason)); }
private transition(next: OrderState) { // (8) ONE place where transitions happen
if (next === this.state) return;
this.log.info({ from: this.state.name, to: next.name, orderId: this.id }, "order transition");
this.state = next;
this.events.push(new OrderStateChanged(this.id, next.name)); // emitted after commit ([9.4.13])
}
get status(): OrderStatus { return this.state.name; } // (9) readable outside, not writable
}Now let us walk through the numbered decisions.
(1) The interface lists every operation, and it is identical for every state.
This is what makes the states substitutable for one another. A Paid object and a Shipped object can both be used wherever an OrderState is expected. Every state answers every message. Some answer by doing the work, and some answer by refusing.
(2) Each operation returns the next state rather than changing anything.
This one choice buys a surprising amount. States never touch the object's internal state field, which means the object needs no public setter. Transitions become visible as return values, which makes them trivial to unit-test, since you simply assert on the returned object without needing any context or database. And the object ends up with exactly one place where a transition happens.
(3) The base class refuses everything by default, so each state only declares what it permits.
Take a moment on this, because it inverts the original bug. In the guard-clause version, forgetting to list a status allowed an illegal operation, which is how paying a refunded order silently succeeded. Here, forgetting to write a method refuses the operation.
Fail closed, never fail open. That is the most important sentence on this page. Human beings forget things, and the design should arrange for forgetting to produce the safe outcome rather than the dangerous one.
Look at Shipped. It has no cancel method at all, and that absence is the business rule. You do not have to read a negation buried inside a guard to learn that a shipped order cannot be cancelled. You just observe that Shipped never mentions cancellation.
(4) One mode per class.
Adding OnHold means creating one new file and adding transitions from whichever states can reach it. Zero existing methods are edited. This is the Open/Closed Principle in its most concrete and most satisfying form.
(5) The state does its work first, then names its successor.
Notice how the state performs its side effects by calling intent-named methods on the object, such as recordPayment and releaseInventory, rather than reaching in and setting fields directly. That keeps the object's invariants intact and it makes each state read like a sentence describing what happens.
(6) The dead-code bug from the story is now structurally impossible.
In the original, cancel set the status to "cancelled" and then checked whether the status was "paid", which was always false. Here, Paid.cancel is the only code that can possibly run when the order is paid, and it issues the refund. The class itself is the guard, so there is no ordering to get wrong.
(7) The object hands operations off and never branches on status.
If you ever see if (this.status === …) reappear inside Order, the pattern has been applied but not adopted, and the benefits are gone.
(8) One transition point gives you a great deal for free.
Because every transition flows through this single private method, you get logging, event emission, persistence hooks and metrics for the entire lifecycle written once. Compare that with the original, where you would have had to remember to log in seven different methods and would inevitably have missed one. You get the audit trail by construction rather than by discipline.
(9) The status is readable from outside but not writable.
The original code's most dangerous property was that order.status = "paid" compiled from anywhere in the codebase. That is now gone. Behaviour can only change through operations that the current state considers legal.
What this does when you run it:
typescript
const order = new Order(); order.place(); // → placed
order.pay(payment); // → paid (logs "placed → paid")
order.ship(carrier); // → shipped
order.cancel("changed mind");
// throws IllegalTransition: cannot 'cancel' while 'shipped'5.1 States without data, and states that carry data
The states above are created fresh on every transition, with new Paid(). That is clear and it is fine. But when a state carries no data at all, prefer a single shared instance, because allocating a stateless object on every transition is pure waste and because identity comparison then becomes meaningful:
typescript
class Paid extends BaseState { static readonly instance = new Paid(); private constructor() { super(); } }When a state genuinely does carry data, such as Cancelled(reason), Shipped(carrier, trackingNumber) or Retrying(attempt, nextAttemptAt), create it per transition and put that data on the state rather than on the object.
This is worth dwelling on, because it is a genuinely better model than the usual approach of one status column plus six nullable columns. With per-state data, cancellationReason is not a field that is "usually null". It only exists when the order is cancelled. That eliminates an entire family of questions of the form "which fields are valid right now?", and it eliminates the bugs that come from reading a field that happens to hold a leftover value from an earlier state. The functional version in section 6.3 makes this even more explicit.
5.2 Python: enums, dataclasses and match
python
from dataclasses import dataclass
from typing import Union
@dataclass(frozen=True)
class Draft: pass
@dataclass(frozen=True)
class Paid: paid_at: datetime; payment_id: str # data that exists ONLY in this state
@dataclass(frozen=True)
class Shipped: carrier: str; tracking: str
@dataclass(frozen=True)
class Cancelled: reason: str
OrderState = Union[Draft, Paid, Shipped, Cancelled]
def ship(state: OrderState, carrier: str, tracking: str) -> OrderState:
match state: # structural pattern matching, Python 3.10+
case Paid():
return Shipped(carrier, tracking)
case _:
raise IllegalTransition(type(state).__name__, "ship")Python's match statement combined with frozen dataclasses gives you the functional form of State. Transitions become pure functions from one state to another, and the illegal cases are raised in exactly one place per operation.
mypy will not enforce exhaustiveness quite as strictly as the TypeScript trick in section 6.3 does, but the shape is identical, and because the dataclasses are frozen, recording a full history of states becomes trivial.
6. Going deeper
6.1 Side effects around transitions: entry actions, exit actions, and ordering
Real machines do work during a transition, not only while sitting in a state. Entering Shipped sends a tracking email. Leaving Reserved releases inventory. Entering Retrying schedules a timer. Leaving Connected closes a socket.
Formal statechart theory names these entry actions and exit actions, and adopting that vocabulary prevents the most common bug in hand-rolled machines, which is putting the side effect in the wrong place so that it runs on some paths and not others.
typescript
export interface OrderState {
readonly name: OrderStatus;
onEnter?(ctx: OrderContext): void; // runs once, when this state is installed
onExit?(ctx: OrderContext): void; // runs once, on EVERY path out of this state
pay(ctx: OrderContext, p: Payment): OrderState;
/* … */
}
private transition(next: OrderState) {
if (next === this.state) return; // (a) no side effects on a self-transition
this.state.onExit?.(this); // (b) exit the old state …
const from = this.state.name;
this.state = next; // (c) … install the new one …
next.onEnter?.(this); // (d) … then enter it
this.log.info({ from, to: next.name }, "transition");
}The ordering here is not arbitrary. It is exit, install, enter, and each position matters.
Exit runs while the old state is still conceptually current, so it can clean up resources that belong to it. The field is updated before onEnter runs, so that anything the entry action triggers sees a consistent object. That is the same rule as "change the state before notifying" from Observer section 5.1, and it exists for the same reason.
The self-transition guard matters more than it looks. Without it, a transition from Retrying to Retrying fires both the exit and the entry actions on every single retry, which typically means the retry timer gets scheduled again on top of the existing one. After a few retries you have a pile of timers, all firing.
The rule for deciding where an action belongs. If it must happen no matter how you leave a state, it is an exit action. If it must happen no matter how you arrive, it is an entry action. If it depends on the specific pair of states involved, it belongs in the transition method itself.
Getting this wrong is precisely how "we forgot to release the inventory when an order timed out" happens. The release lived inside cancel() rather than in Reserved.onExit, so the timeout path skipped it entirely, and the inventory quietly leaked one order at a time.
6.2 The transition table: making the whole machine visible at once
Spreading transitions across state classes is clean, and it answers the question "what can Paid do?" very well. It answers the question "show me the whole machine" rather badly, because the answer is spread across ten files.
When the machine is large, or must be audited, visualised or configured, invert the arrangement. Keep behaviour in the states, and move the transitions into one data structure:
typescript
const TRANSITIONS: Readonly<Record<OrderStatus, Partial<Record<OrderEvent, OrderStatus>>>> = {
draft: { place: "placed", cancel: "cancelled" },
placed: { pay: "paid", cancel: "cancelled", expire: "expired" },
paid: { ship: "shipped", cancel: "cancelled", refund: "refunded" },
shipped: { deliver: "delivered", lose: "lost" },
delivered: { return: "returning" },
cancelled: {}, refunded: {}, expired: {}, lost: {}, returning: { receive: "refunded" },
};
export function next(state: OrderStatus, event: OrderEvent): OrderStatus {
const to = TRANSITIONS[state][event];
if (!to) throw new IllegalTransition(state, event);
return to;
}Doing this buys you a set of properties that are genuinely hard to obtain any other way.
The machine becomes one screen you can read, which means a product manager or a compliance reviewer can check it without reading code. You can generate a diagram from it automatically, so the documentation cannot drift out of date. You can test it exhaustively by iterating every state and every event and asserting the outcome, which is a complete test of the machine in about ten lines. You can assert structural properties, such as every state being reachable from the initial one, every terminal state genuinely having no outgoing transitions, and no state being a dead end where the object gets permanently stuck. And because the type is Record<OrderStatus, …>, the compiler demands an entry for every status, so adding a new status is a build error until you have defined its transitions.
The cost is that behaviour and transitions now live in separate places, so both have to be kept honest. The mitigation is to derive one from the other rather than maintaining two independent lists.
Which form to use when. Use classes with successors for small and medium machines that have rich per-state behaviour. Use a transition table when the machine is large, needs visualisation or audit, or when non-engineers have to review the lifecycle. Reach for a dedicated state-machine library when you need states nested inside other states, several machines running side by side, or a record of where the machine has already been. Every language has one or two well-known ones. That is the point where writing it yourself stops being the cheaper option.
6.3 Type-level states: making illegal operations fail to compile
The strongest version of State moves enforcement from runtime all the way to compile time, using a discriminated union (3.7.3). This is the functional spelling, and in TypeScript it is often the better tool when the machine's rules matter more than its per-state behaviour:
typescript
type Order =
| { status: "draft"; items: Item[] }
| { status: "placed"; items: Item[]; placedAt: Date }
| { status: "paid"; items: Item[]; placedAt: Date; paymentId: string } // ← paymentId
| { status: "shipped"; items: Item[]; placedAt: Date; paymentId: string; tracking: string }
| { status: "cancelled"; items: Item[]; reason: string }; // EXISTS ONLY HERE
function ship(order: Extract<Order, { status: "paid" }>, tracking: string): Order { // (1)
return { ...order, status: "shipped", tracking };
}
declare const o: Order;
ship(o, "T1"); // ✗ compile error: only a paid order can be shipped
if (o.status === "paid") ship(o, "T1"); // ✓ the check proves it to the compiler
function label(o: Order): string {
switch (o.status) {
case "draft": return "Draft";
case "placed": return "Awaiting payment";
case "paid": return "Paid";
case "shipped": return `Tracking ${o.tracking}`; // (2) tracking is available here, only here
case "cancelled": return `Cancelled: ${o.reason}`;
default: { const _exhaustive: never = o; return _exhaustive; } // (3) a new state breaks the build
}
}(1) The function's parameter type is the precondition. Because ship accepts only an order whose status is "paid", calling it with an arbitrary order is a compile error rather than a runtime exception. The guard clause has become a type, which means it is checked before the code ever runs.
(2) Data that belongs to one state exists only in that state. The tracking field is present on the shipped variant and nowhere else, so writing o.tracking outside a shipped branch does not compile. That kills the "six nullable columns and nobody knows which are valid" problem at the root rather than patching around it.
(3) The never assignment turns exhaustiveness into a build error. Add "on_hold" to the union, and every switch in the codebase that does not handle it fails to compile. The compiler is now performing the audit that the guard-clause version required a human to perform from memory, and the compiler never forgets.
Choosing between the two forms. Use the class form when states have substantial behaviour, when you need polymorphic dispatch across many operations, or when other modules must be able to add new states. Use the union form when the rules matter more than the behaviour, when the data genuinely differs between states, or when you want the compiler to enforce completeness. Both are State. The union form is what a functional language would call the same design, and in TypeScript it is frequently the stronger tool.
6.4 Persisting a state machine
The state lives in memory as an object but in the database as a value, which raises three concerns that each need an explicit answer.
Mapping. Persist the tag, meaning the string "paid", and never a serialised object. Rehydrate through a single factory, so STATES[row.status](). Declaring that factory map as Record<OrderStatus, () => OrderState> makes the compiler require an entry for every status.
Concurrency, which is the point candidates most often miss. Two requests that both read the status "paid" and both call ship will both succeed in memory and then race in the database. The state object cannot help you here at all, because it only knows about the copy in this process.
Enforcement has to happen in the store. Use optimistic locking, so UPDATE orders SET status='shipped', version=version+1 WHERE id=? AND version=?, where zero rows updated means somebody else transitioned first, so you reload and decide again. Or use a conditional update on the expected state, so WHERE id=? AND status='paid', which expresses the same idea more directly and reads better.
The sentence to remember is that the transition must be atomic in the store, not merely correct in the model (10.4).
History and audit. Persist the transitions, not just the current state, storing (order_id, from, to, event, actor, at, correlation_id). Because the object has exactly one transition point, this is one insert written in one place. It turns "why is this order in this state?" from an archaeology project into a single query.
For systems where the history is the truth rather than a record of it, this becomes event sourcing: the state is a fold over the transition log, and the status column is simply a cache of the result.
7. Where you would actually use this
(a) Order, payment and subscription lifecycles. The everyday case. Orders move from draft through placed, paid, shipped and delivered, with branches for cancelled, refunded and returned. Payments move from pending through authorized, captured and settled, with failed, reversed and disputed. Subscriptions move from trialing through active, past_due, cancelled and expired. Every one of these has both a compliance dimension and a money dimension, which is why the audit trail from a single transition point matters as much as the correctness does.
(b) Connection and protocol machines. TCP is the textbook example, moving from CLOSED through LISTEN, SYN_RCVD, ESTABLISHED, FIN_WAIT_1 and eventually TIME_WAIT. It is worth studying because it demonstrates three things at once: timers acting as events, states that exist purely to wait, and why TIME_WAIT deserves to be a state rather than a boolean flag. WebSocket connections, database connection pools and HTTP/2 stream states are all the same shape.
(c) The circuit breaker. Closed, then Open, then Half-Open, then back to Closed, driven by failure counts and timers (10.9). It is a perfect small machine: three states, both event-driven and timer-driven transitions, and per-state data such as the failure count and the time it opened. If an interviewer asks you to implement a circuit breaker, they are asking you to implement State.
(d) The classic interview machines. Vending machine, elevator, ATM, traffic light, turnstile, parking gate (9.7.3, 9.7.28, 9.7.4). These are chosen precisely because they are small enough to finish inside an interview and rich enough to reveal whether you understand entry and exit actions, illegal transitions, and timers.
(e) User interface and form flows. Idle, then loading, then success or error, is the machine that every component secretly is. It is also the reason that combinations like isLoading && !error && data produce impossible interface states: three booleans encode eight combinations, of which four are nonsense. Replacing them with one union eliminates the impossible states by construction, and this is the most immediately useful application of section 6.3 for a frontend engineer.
(f) Media players and games. Stopped, playing, paused, buffering, ended. Also enemy AI, moving between patrolling, chasing, attacking and fleeing, where State is the standard game-development idiom and entry and exit actions typically drive animations.
(g) Deployment and CI/CD pipelines. Queued, running, then succeeded, failed or cancelled, with retries and approval gates. Usually modelled with an explicit table because the lifecycle has to be displayed in a user interface and audited afterwards.
(h) Document and approval workflows. Draft, in review, approved or rejected, published, archived, with different permissions in each state. This is the case where the transition table is clearly the right form, because non-engineers need to read and change it.
8. Variants
| Variant | What it looks like | Notes |
|---|---|---|
| Classic State (GoF) | a state class per mode; the state changes the object | direct, but transitions happen in many places |
| Returning the successor | operations return the next state | the recommended default; one transition point, easy tests |
| Singleton states | one shared instance per stateless mode | avoids allocation; identity comparison becomes meaningful |
| States that carry data | Cancelled(reason), Shipped(carrier) | replaces nullable columns with per-state fields |
| Transition table | a data structure of (state, event) → state | visualisable, exhaustively testable, auditable |
| Discriminated union | type-level states with narrowed parameters | illegal operations do not compile |
| Statechart | hierarchical and parallel states, history, guards | XState and SCXML; for genuinely complex machines |
| Entry and exit actions | onEnter and onExit hooks | side effects that must run on every path in or out |
| Event-sourced state | state is a fold over a transition log | the history is the truth; audit comes free |
| Explicit terminal states | end states with no outgoing transitions | makes "the end" explicit rather than implied |
Statecharts deserve a proper explanation, because they are what you graduate to when a flat machine stops being enough.
Harel statecharts add four things. Hierarchical states let you nest a group, so a Connected superstate containing Idle, Sending and Receiving means "on disconnect, go to Closed" is written once for the whole group instead of separately on every substate. That alone removes the most common source of duplication in large machines. Parallel regions let an object be in two states at once along different dimensions, so a media player can be playing and muted simultaneously without needing a combined playing_muted state. History states let you resume where you left off after an interruption. And guarded transitions let the same event lead to different destinations depending on a condition.
The rule of thumb: hand-roll while the machine fits on one screen and has no independent dimensions. Adopt a statechart library the moment you find yourself writing the same transition on five different states, or inventing combined names like paused_buffering_muted.
9. Where you already use it
| What you have used | The states it moves through |
|---|---|
A Promise | pending, then either fulfilled or rejected — and never again |
| An order you placed online | placed, preparing, out for delivery, delivered |
| A traffic light | red, green, amber, and back to red |
| A pull request | open, then merged or closed |
| A pattern-matching engine | the exact mathematical version of this idea (1.7) |
The promise is the sharpest example because its rules are strict and you can check them yourself. A promise starts pending. It then moves once, to fulfilled or to rejected, and after that it never moves again. Call resolve a second time and nothing happens — no error, no change, the call is simply ignored.
That is a state machine doing its job. "You may only settle once" is not enforced by everyone who touches a promise remembering to check. It is enforced by the promise itself, which knows which state it is in and therefore knows which moves are still allowed. This is the same guarantee that 9.2.2 argued for with a wallet balance, applied to which operations are legal right now instead of to a number.
10. Ways to get it wrong
Building a state machine for two states.
isPublisheddoes not need aPublishedStateclass.The fix: a boolean or an enum with guards, until there is a third state and real per-state behaviour.
The object still branching on status.
if (this.state.name === "paid")anywhere in the object means the pattern was applied but not adopted.The fix: add a method to the state interface, such as
canRefund()orlabel().Defaults that fail open. A base state that silently ignores unknown operations rather than throwing.
The fix: default to
IllegalTransition. Ignoring is a choice an individual state may make explicitly, such as callingplay()on an already-playing player, but it must be written down rather than inherited by accident.Side effects in the wrong hook. Inventory released inside
cancel()rather than inReserved.onExit, so the timeout path leaks.The fix: the placement rule from section 6.1.
Self-transitions firing entry and exit actions.
Retrying → Retryingreschedules the timer every time.The fix: guard with
next === current.Transitions racing in the database. Two
shipcalls both pass the in-memory check.The fix: a conditional update on the expected state, or optimistic version locking.
A machine spread across services. Transitions performed by three different services with no clear owner.
The fix: one service owns the lifecycle, and the others request transitions through it.
Combinatorial state explosion. Names like
paused_muted_buffering.The fix: separate machines for separate dimensions, which is what parallel regions in statecharts provide.
States reaching deep into the object. The fix: the object exposes intent-named operations, and states call those.
No history. Only the current status is stored, so "why is this refunded?" cannot be answered.
The fix: persist transitions from the single transition point.
- Transitions triggering further transitions inline. An
onEnterthat immediately transitions again produces re-entrancy bugs, because the state field is being modified in the middle of a transition.
The fix: queue the follow-up event and process it once the current transition has completed.
11. State compared with its neighbours
| Compared with | The difference | Choose State when |
|---|---|---|
| Strategy | identical structure. A strategy is chosen from outside and stays, and strategies do not know each other. States install their own successors and encode the transitions | behaviour changes over a lifecycle in response to events |
| Command | a Command is a request object; a State is a mode object. Commands often drive transitions | you are modelling modes rather than requests |
| Chain of Responsibility | a Chain decides who handles a request; State decides what a handler does right now | the variation is about time, not about routing |
Enum plus switch | an enum with per-case behaviour in one switch is the same machine, written more densely | there are several operations, each varying per state |
| Discriminated union | not an alternative at all — it is State in functional clothing (section 6.3) | use it when data differs per state and the rules dominate |
| Workflow engine | durable, distributed, long-running, with persistence and retries built in | the machine is in-process and short-lived |
The Strategy-versus-State answer, in the form an interviewer wants to hear: "They are structurally identical, since both are an object holding an interface with several implementations, but the driver is opposite. A Strategy is chosen from outside, stays put for the operation, and the strategies are mutually unaware, because they are alternative answers to one question. A State is installed by the object itself as events arrive, the states know their successors, and they are phases of one lifecycle. Concretely, if the field is assigned once in the constructor and never reassigned, it is Strategy. If the implementations return or set the next implementation, it is State."
12. Interview calibration
The 45-second answer, in the order you would say it:
State makes each mode of an object's lifecycle its own class implementing a common set of operations, so that behaviour and legality come from the current mode instead of from
if (status === …)guards scattered through every method. The trigger is a status field read in more than two methods, with transition rules nobody can draw.Each state implements only what it permits, and a base class refuses everything else, so forgetting a case fails closed rather than silently allowing an illegal operation. I have operations return their successor, which gives the object exactly one transition point, and that is where I log, emit the state-changed event and write the history row.
For large or auditable machines I move the transitions into a table, so the whole machine is one readable screen and I can test every state-event pair exhaustively. In TypeScript I often use a discriminated union instead, so illegal operations do not compile and per-state data such as a tracking number only exists on the shipped variant.
The detail people miss is that the transition must be atomic in the database too, using a conditional update on the expected status, because otherwise two concurrent requests both pass the in-memory check.
Follow-up questions, with the seed of each answer:
- "State versus Strategy?" — Same structure, opposite driver: chosen from outside and staying, versus installing its own successor as events arrive.
- "Where do side effects go?" — Entry actions for "however you arrive", exit actions for "however you leave", and the transition method for work specific to one pair. Guard self-transitions.
- "How do you persist it?" — Store the tag, rehydrate through a factory map, enforce transitions with a conditional or optimistic update, and write a transition history from the one transition point.
- "When would you use a library?" — Hierarchical or parallel states, history states, or when the machine no longer fits on one screen. That is where hand-rolling stops being cheaper.
- "Isn't a class per state heavy?" — Yes, for two states, which is why the trigger is several modes with real per-mode behaviour. Otherwise use an enum or the union form.
- "How do you test it?" — Each state as a pure function, every state-event pair against an independently written table, plus reachability and dead-end checks.
Recall
- State means one class, or one union variant, per mode. The object hands every operation to its current mode, and that mode decides both what happens and what comes next. The trigger is a
statusfield read in more than two methods, with transition rules nobody can draw. - How you arrive at it: what varies is the behaviour and legality of every operation in each mode, and what stays fixed is the set of operations. The choice is made at runtime and is driven by the object itself, which is the entire difference from Strategy.
- Fail closed, never open. A base state that throws
IllegalTransitionby default means each state declares only what it permits, so a forgotten case is refused rather than silently allowed.Shippedhaving nocancelmethod is the business rule. - Return the successor rather than changing the object. One transition point gives you logging, state-changed events, persistence and audit history for free, and it turns every state operation into a pure function you can test by asserting on the returned state.
- Entry and exit actions: the order is exit, install, enter, and self-transitions must be guarded. Something that must happen however you leave is an exit action, which is the fix for the classic "we forgot to release inventory on timeout" bug. Something that must happen however you arrive is an entry action.
- The transition table makes the whole machine one readable screen: you can generate a diagram from it, test every state-event pair exhaustively, check reachability and dead ends, and
Record<Status, …>makes adding a status a build error until you define its transitions. - Type-level states using a discriminated union: the parameter type is the precondition, so illegal calls do not compile. Per-state data exists only in its own variant, which removes the nullable-column problem. And a
neverdefault makes exhaustiveness a build error. - Persistence: store the tag, rehydrate through a factory map, and make the transition atomic in the store using
WHERE status='paid'or an optimistic version check. A machine that is correct in memory and racy in the database is not correct. Persist the transition history, not just the current status. - Common mistakes: a machine for two states · the object still branching on status · defaults that fail open · side effects in the wrong hook · self-transitions firing entry and exit · combined names like
paused_muted_buffering· no history.
Self-test: Why must the default be refusal rather than silently ignoring? What does returning the successor buy over changing the object directly? Where does an action belong if it must run however you leave a state? Give the one-sentence State-versus-Strategy answer. What breaks if the transition is only enforced in memory?
Quiz Bank
FoundationalShow how State is derived from a status field with guard clauses, and identify the specific bug class the guard version creates.
The naive starting point is a status field plus if (status !== …) throw guards in each method. That is genuinely correct for two or three states in a straight line, and it is preferable there.
The force is that the object has several modes, each method behaves differently in each mode, and the modes transition according to rules.
What the guard version costs. First, the machine exists but nowhere in particular, since the transition rules are scattered as guards, so answering "can a delivered order be refunded?" requires reading seven methods and no artefact shows the lifecycle. Second, every new status means editing every method, and you will miss one — the standard example being a pay method that guards against draft, paid, cancelled, shipped and delivered but forgets refunded, so paying a refunded order silently succeeds. Third, guards written as a list of negations make gaps invisible, because the compiler cannot check an ad-hoc chain of booleans for completeness. Fourth, illegal transitions surface as production data corruption rather than as compile errors. Fifth, behaviour and transition are tangled in one method, so neither can be tested alone. Sixth, the status field is publicly writable, so order.status = "paid" from any file bypasses every guard that was ever written.
The signature bug class is order-dependent dead code. The cancel method assigns status = "cancelled" and then checks if (this.status === "paid") this.refund(), which is now always false. The refund never happens, no test catches it because the branch reads correctly in isolation, and the financial loss appears in a reconciliation weeks later.
Drawing the line: the per-mode behaviour and legality vary, while the set of operations is fixed.
When the choice happens: at runtime, driven by the object itself, which installs its own successor.
The pattern is one class per mode implementing the fixed operation set, a base class that refuses by default, operations that return their successor, and a single transition point inside the object. The dead-code bug becomes structurally impossible, because Paid.cancel is the only code that can run when the order is paid, and it refunds.
What you accept: a class per state, transition logic spread out unless you add a table, an object that must expose enough for states to act, and a persistence mapping.
FoundationalExplain 'fail closed' in a State implementation, and why returning the successor beats changing the object directly.
Fail closed means the base state implements every operation as throw new IllegalTransition(this.name, op), and each concrete state overrides only the operations it actually permits.
The consequence is that leaving something out refuses rather than allows. This inverts the guard-clause version's failure mode. There, forgetting to list a status in a guard permitted an illegal operation, which is how paying a refunded order silently succeeded. Here, forgetting to implement an operation refuses it. Since forgetting is the mistake human beings actually make, aligning that mistake with the safe outcome is the highest-leverage decision in the whole implementation.
It also makes the code read as a positive specification rather than a negative one. The Shipped class contains no cancel method, and that absence states the business rule "a shipped order cannot be cancelled" far more legibly than a negation buried inside a guard clause ever could.
One refinement is worth noting. Ignoring an operation is sometimes the correct behaviour, since calling play() on an already-playing media player should be a harmless no-op rather than an exception. But that must be an explicit override written inside that state, never a silent default inherited by everything.
Returning the successor rather than changing the object directly buys four things.
The first is testability. Writing new Paid().ship(ctx, carrier) returns a Shipped object, so you simply assert on the return value. There is no lifecycle to drive, no database, and no need to spy on a setter. The transition has become a pure function.
The second is a single transition point. The object's private transition(next) method is the only place the state field is ever assigned, so logging, the state-changed event, persistence, metrics and the audit history row are each written once and cannot be forgotten when somebody adds a new state. Compare that with the version where every state calls ctx.setState(...), and all those cross-cutting concerns must either be duplicated or hidden inside the setter.
The third is that states stay ignorant of the object's internals. They never touch its state field, so the object needs no public setter and its invariants cannot be bypassed by anything else in the codebase.
The fourth is composability. Because the operation is now (state, event) → state, you can validate a whole sequence of events with no side effects at all, dry-run a machine before committing to it, or reconstruct history by folding a transition log.
The Gang of Four form, where the state changes the object, is not wrong. It simply scatters transitions across many sites and requires an exposed setter, while the returning form achieves the same behaviour with strictly better properties.
AppliedSide effects around transitions: entry actions, exit actions, and self-transitions. Give the placement rule and the bug each wrong placement causes.
Real machines do work during a transition, not only while sitting in a state, and where you put that work determines which paths actually execute it.
An entry action runs whenever the state is installed, regardless of which predecessor or which event brought you there. An exit action runs whenever the state is left, regardless of which successor or event takes you out. A transition action runs only for one specific combination of from-state, event and to-state.
The placement rule is to ask three questions in order. Must this happen however I arrive? Then it is an entry action. Must it happen however I leave? Then it is an exit action. Does it apply only to this one specific edge? Then it belongs in the transition method.
The bugs each wrong placement causes. Putting exit logic inside one operation instead of onExit means inventory is released inside cancel(), so when the order leaves Reserved via the timeout path or an admin void path, the inventory is never released at all. That is the classic slow leak, and it surfaces as phantom out-of-stock items weeks later. Putting entry logic inside one operation instead of onEnter means the tracking email is sent inside ship(), so an order that reaches Shipped through a bulk import or a support-tool correction never notifies the customer. Putting transition-specific work into an entry action means Cancelled.onEnter issues a refund, so cancelling an unpaid order attempts to refund money that was never taken. That action belonged on the paid-to-cancelled edge specifically.
The ordering is fixed and it is not arbitrary: exit, install, enter. Exit runs while the old state is still conceptually current, so it can clean up resources that belong to it. The field is assigned before onEnter runs, so that anything the entry action triggers — an emitted event, a callback into the object — observes a consistent object. That is the same rule as Observer's "change the state before notifying".
Self-transitions must be guarded with if (next === current) return. Without that guard, a Retrying → Retrying transition fires exit and entry on every attempt, rescheduling the retry timer each time, which typically produces exponentially growing numbers of pending timers. Similarly, a "refresh" event that maps a state to itself would tear down and rebuild resources for no reason. If you genuinely want re-entry semantics, such as restarting a timeout on every heartbeat, that must be an explicit and documented self-transition rather than an accident.
Two further hazards worth naming. An entry action that immediately triggers another transition creates re-entrancy, because the state field is being modified in the middle of a transition. Queue the follow-up event and process it after the current transition completes. And side effects that can fail, such as sending an email or calling a payment API, must not leave the machine half-transitioned. Either perform them after the transition commits, making them retryable, or treat their failure as an event that drives the machine into an explicit Failed state.
InterviewModel a payment intent lifecycle for a public API: requires_payment_method, requires_action, processing, succeeded, failed, cancelled, plus refunds and disputes. Address concurrency, idempotency, webhooks and evolution.
The design. The machine is the product here, because the state names are part of the public API contract. That means they must be named for what an integrating developer sees rather than for internal steps, and once published they can never be removed.
The core flow is requires_payment_method → requires_confirmation → requires_action for 3-D Secure, then processing, then either succeeded or failed. The cancelled state is reachable from every state before processing. Refunds and disputes are usually a separate machine that references the payment rather than more states bolted onto it, and that separation is the discipline that keeps a machine comprehensible: refusing to model independent lifecycles as extra states in the same one.
The representation should be a transition table, because this machine has to be published as documentation, rendered as a diagram, reviewed by non-engineers, and tested exhaustively. Per-state behaviour is thin here, and the rules are the substance.
Concurrency is the crux. A confirm request, a webhook from the acquirer, and a cancel request can all arrive simultaneously for the same intent. The in-memory state check is not enforcement. Every transition must be a conditional update such as UPDATE intents SET status='processing', version=version+1 WHERE id=? AND status='requires_confirmation' AND version=?, where zero rows updated means somebody else moved it first. The handler then reloads and decides whether its operation is now a harmless no-op returning success, a genuine conflict returning 409, or still valid. Do not use a read-then-write inside a transaction without the condition, because under read-committed isolation both readers see the old state and both proceed.
Idempotency. Clients retry and networks duplicate. Every mutating call takes an idempotency key, stored alongside the request fingerprint and the response, and a replay returns the stored response rather than re-running the transition (9.6.3). Combined with conditional updates, that gives exactly-once effects under at-least-once delivery. Additionally, make transitions naturally idempotent where you can: confirming an already-processing intent should return the current state rather than erroring, because a client whose request timed out genuinely cannot tell whether it was received.
Webhooks are Observer over HTTP with an outbox behind them. Each transition emits a payment_intent.<new_state> event written to an outbox in the same transaction as the state change, delivered by a relay with retries and exponential backoff, signed, and carrying the full object plus a monotonically increasing version. Two properties must be stated in your documentation because integrators depend on them: delivery is at-least-once, so consumers must deduplicate on event identifier, and out-of-order delivery is possible, so events carry the object version and consumers must discard stale ones. Provide a replay endpoint so that a consumer who was down can catch up without raising a support ticket.
Terminal states must be genuinely terminal, because integrators build on that guarantee. Once an intent reaches succeeded, its status never changes again. A refund creates a refund object; it does not move the intent to refunded. Violating this after publication breaks every integration that was written against the original guarantee, which is exactly why the refund and dispute machines are kept separate.
Evolution. New states must be additive, and they must be introduced with documented guidance for existing clients, because a client with a switch on status will break on an unknown value. Publish the instruction "treat unknown statuses as processing" before you need it, or gate new states behind an API version. Never reuse a state name with different meaning. Version the webhook payload, and run consumer-driven contract tests.
Observability. Persist every transition with the actor, the cause, the correlation identifier and the acquirer's raw response, because the most common support question is "why is this intent in this state?" and a transition log answers it in one query. Alert on intents stuck in non-terminal states past an agreed threshold, because a machine with no stuck-state alarm accumulates zombies indefinitely.
The summary sentence: publish the states as a contract, keep independent lifecycles such as refunds and disputes as separate machines, enforce every transition as a conditional update rather than an in-memory check, make every mutation idempotent by key, deliver state changes through an outbox with versions so consumers can deduplicate and discard stale events, and treat terminal states as promises you can never take back.
StaffA logistics platform has order lifecycle logic in four services — checkout, warehouse, carrier integration and support tooling — each with its own status enum and transition rules. Orders regularly end up in impossible states. Design the fix.
The diagnosis is that the lifecycle has no owner. Four partial machines each enforce a subset of the rules, and the places where they disagree are the impossible states. The support tool sets cancelled on an order the warehouse already marked picked. The carrier integration writes shipped for an order that checkout still believes is pending_payment. No amount of care inside each service can fix this, because the rule being violated is global while every check is local.
The fix has four parts, in dependency order.
First, a single writer. One service owns the order lifecycle and is the only thing permitted to change status. Every other service requests a transition through an API, sending {event, idempotencyKey, actor, evidence}, and observes the result through events. That is the whole fix structurally, and everything else is about making it adoptable. Note what this is not: it is not "put all the logic in one service". The warehouse still decides when picking is complete. It simply no longer gets to write the order's status directly.
Second, one machine definition, shared as data. The transition table lives in one package published to all four services, with generated types for each language, so the warehouse's user interface can grey out illegal actions using the same table the owner enforces. Clients get responsiveness, the owner keeps authority, and there is exactly one artefact to change when the lifecycle evolves.
Third, enforce atomically and idempotently. Transitions are conditional updates on the expected current state. Concurrent requests are resolved by whoever wins, and the loser receives the current state and decides what to do. A cancel request that loses to a ship should return a clear "cannot cancel, already shipped" message that the support tool can act on, not a generic 500. Every request carries an idempotency key, because retries across four services are constant.
Fourth, reconcile and detect, because you have inherited a corrupt dataset. Ship a continuously running invariant checker looking for orders in terminal states with open shipments, orders marked paid with no payment record, orders untouched in a non-terminal state past a threshold, and carrier events referencing orders in impossible states. Alert on new violations, since those indicate a hole in the enforcement, and drive a remediation queue for the old ones. Without this you cannot demonstrate that the fix worked.
The migration is the hard part, because four teams are writing today.
Phase zero is instrumentation. Log every status write from every service with the actor and the previous value. This produces the real transition graph, which will contain edges nobody documented, and that graph rather than the design document is the specification you must satisfy.
Phase one is building the owner service with the table, and having it consume the existing writes in shadow mode, computing what it would have done and alerting on divergence. Divergences are either bugs in your table, which you fix, or bugs in the writers, which becomes evidence for phase two.
Phase two converts writers into callers, one service at a time, starting with the lowest-volume one. Support tooling is the right place to begin, because it has the smallest blast radius and its operators can report problems directly.
Phase three revokes write access at the database or schema level. This must be a permission rather than a convention, because a convention is exactly what failed in the first place.
Phase four deletes the local enums and replaces them with the generated shared type.
There is an organisational reality worth naming out loud. This is a Conway's-law problem as much as a technical one. If the four teams cannot agree on who owns the lifecycle, the code will fragment again within two quarters regardless of how good the design is. Part of the deliverable is therefore a named owning team, a change process for the table consisting of a pull request to one repository reviewed by that owner, and a service-level agreement for transition requests so that other teams are not blocked waiting.
How you measure success: invariant violations per day trending to zero, the count of services with write access dropping from four to one and being enforced by permissions, the time to add a lifecycle state dropping from weeks across four repositories to a single pull request, and the number of support tickets caused by impossible states.
The summary sentence: impossible states are the signature of a lifecycle with no owner, so give it a single writer, publish the machine as shared data so clients can predict but only the owner can decide, enforce every transition as an atomic conditional update with idempotency keys, and run a permanent invariant checker, because you must be able to prove that the corruption stopped.
Flashcards
FlashState in one line
One class, or one union variant, per mode. The object hands every operation to its current mode, and that mode decides both what is legal and what comes next.
FlashState: fail closed
The base state throws IllegalTransition for every operation, and each state overrides only what it permits. Leaving something out refuses instead of allowing. Shipped having no cancel IS the rule.
FlashState: return the successor
Operations return the next state and the object installs it in one place. That gives pure-function tests plus one site for logging, events, persistence and audit history.
FlashEntry versus exit actions
However you arrive is an entry action. However you leave is an exit action. One specific edge belongs in the transition method. Order is exit, install, enter; guard self-transitions or timers double up.
FlashState: persistence and concurrency
Store the tag and rehydrate through a factory map. Enforce with a conditional update such as WHERE status='paid', because in-memory checks lose to concurrent requests.
FlashState as a discriminated union
The parameter type is the precondition, so illegal calls do not compile. Per-state data exists only in its own variant. A never default makes exhaustiveness a build error.
Scenario Drill
DrillDesign the state machine for a food-delivery order spanning restaurant, courier and customer: placement, restaurant acceptance, preparation, courier assignment, pickup, delivery, plus cancellations at every stage, restaurant rejection, no couriers available, and courier abandonment. Show the machine, where money and side effects attach, and the failure modes.
The instructive difficulty here is that three actors act concurrently on one order. A single flat machine either explodes into a combinatorial mess or hides genuine concurrency behind a fake sequence.
Decompose into independent machines first, which is the senior move. Three lifecycles run in parallel and reference each other. Order fulfilment runs placed → accepted → preparing → ready → picked_up → delivered, with rejected, cancelled and refunded as exits. Courier assignment runs unassigned → offered → assigned → en_route_to_restaurant → at_restaurant → en_route_to_customer → completed, with abandoned and reassigning. Payment runs authorized → captured → refunded or partially_refunded.
Modelling these as one machine produces state names like preparing_courier_assigned_payment_authorized, and that combinatorial explosion is the signal telling you the dimensions are independent and belong in parallel regions or, more practically, in separate machines with documented cross-constraints.
The cross-machine constraints are the real business rules, so state them explicitly. Courier assignment may not start before accepted, or you dispatch couriers for orders that restaurants will reject. But it should start before ready, or the food sits going cold. Payment capture happens at accepted, not at placed, because you must never charge for an order no restaurant accepted, and not at delivered, because you must not cook for a card that will decline. And fulfilment cannot reach delivered unless the courier machine has reached completed.
Time-driven transitions are first-class here, not an afterthought. Every waiting state needs a timeout with a defined destination. A restaurant that does not respond within three minutes triggers auto-rejection and a refund. If no courier accepts within five minutes, the offer escalates by raising the fee and retries, and after several failures the order cancels with a full refund. A courier idle at the restaurant for ten minutes alerts support.
A waiting state without a timeout is a stuck order and a support ticket. The discipline is that the machine must contain no state in which nothing can possibly happen.
Where side effects attach. Accepted.onEnter captures payment, starts the preparation timer, and begins the courier search. Ready.onEnter notifies the assigned courier. PickedUp.onEnter starts live tracking for the customer and freezes the estimated arrival promise.
Cancelled is the interesting one, and it is the reason transition actions exist as a separate concept. The compensation depends on when the cancellation happened, so it cannot be an entry action. Cancelled before accepted means voiding the authorisation with no fee. Cancelled after accepted, when food preparation has started, means a refund minus restaurant compensation, and the restaurant still gets paid. Cancelled after picked_up means a full charge, the courier is paid, and a support agent resolves it. Encoding "the refund policy depends on which state you left from" as edge behaviour is what keeps the money rules honest and auditable.
Courier abandonment is the failure mode that separates a real design from a whiteboard one. The courier machine moves from assigned to abandoned, which does not cancel the order. It triggers reassigning, keeps the fulfilment machine at ready because the food already exists, boosts the offer, and starts a shorter timeout. After several failed reassignments the order cancels with a full refund and the restaurant is still compensated, because somebody cooked that food.
Critically, fulfilment must never regress from picked_up. Once food is with a courier, abandonment is a recovery workflow — return to restaurant, or reassign in place — never a rewind. The rule is to never model recovery as a backwards transition into an earlier state. Moving only forward into explicit recovery states keeps the history meaningful and prevents loops.
Concurrency. Restaurant acceptance and customer cancellation will eventually arrive in the same second. Both must be conditional updates on the expected state, and the loser must get a deterministic outcome: a cancellation that loses to an acceptance becomes a post-acceptance cancellation with the corresponding refund policy, rather than an error. Courier offer acceptance is a compare-and-set on the assignment, using WHERE courier_id IS NULL, because otherwise two couriers both believe they won the same order, which is the classic double-assignment bug.
Persistence and audit. Every transition in all three machines is persisted with the actor, timestamp, correlation identifier and triggering event. Disputes such as "the courier says they delivered it" are settled from this log combined with GPS traces, which makes the log a business asset rather than a debugging convenience.
Observability. Per-state dwell-time histograms turn the machine into the operational dashboard: the ninety-fifth percentile time spent in preparing per restaurant, the offer acceptance rate, the abandonment rate by region. Plus alerts for any order sitting in a non-terminal state beyond its threshold.
The summary sentence: three independent machines for fulfilment, courier assignment and payment, with explicit cross-constraints, timeouts on every waiting state so nothing can get stuck, compensation attached to transition edges because the refund policy depends on where you left from, forward-only recovery states instead of backwards transitions, and conditional updates everywhere, because a restaurant and a customer will always eventually act in the same second.