State Machines
States, transitions, guards and effects as a table the code reads — so the lifecycle is data you can review rather than control flow you have to reconstruct.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
How do I express a lifecycle so that the legal moves, their preconditions and their side effects are all visible in one place?
Order fulfilment now has guards ("only ship if every line is picked"), effects ("notify the customer, decrement stock") and three new transitions requested this quarter. The logic currently lives in six handlers, each with its own precondition checks.
Each handler checks the preconditions it cares about and then updates the status. The checks live where the action lives, which is where you would look for them.
The set of legal transitions is now spread across six files and exists nowhere as a whole, so "can this order be cancelled?" can only be answered by reading all six (Local Reasoning).
- The set of legal transitions is now spread across six files and exists nowhere as a whole, so "can this order be cancelled?" can only be answered by reading all six (Local Reasoning).
- Two handlers implement the same guard differently — one checks
every line picked, the other checkspickedCount === lineCount, and they disagree for orders with a cancelled line. - The admin screen showing available actions is hand-maintained and drifts from what the handlers actually permit, so ops sees buttons that fail on click.
- Adding a state means finding every handler that switches on status and adding a case — and the one that is missed silently falls through to a default that does nothing (Shotgun Surgery).
- Effects and state writes are interleaved differently in each handler, so a failure halfway through leaves a different kind of mess depending on which action was being performed.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- Effects include external calls that can fail after the state has been written, so the machine must be safe to re-run.
- Ops needs an admin screen showing which actions are currently available on an order, generated rather than hand-maintained.
- The team must be able to add a state without touching six files.
- An order is in exactly one state, and every change is a transition defined in the table.
- A transition's guard is evaluated before its effects, and its effects run at most once per successful transition.
- The set of legal actions in a state is derivable from the table, not from reading handlers.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The transition table owns which moves exist, their guards and their effects — one artefact, reviewable in one sitting.
- The machine owns evaluating guards before effects and writing the state exactly once.
- Guards own answering a question and changing nothing; effects own changing things and deciding nothing. Mixing them is what makes lifecycles untestable (Side Effects).
- The handlers own translating a request into a transition attempt, and nothing else — they become thin (Transaction Script).
- The machine boundary is the only place the state field is written, which is what makes the invariant enforceable (State Ownership).
- Effects sit outside the pure core: the machine decides which effects a transition implies, and an imperative shell performs them (Functional Core, Imperative Shell).
- The boundary between the machine and persistence is where atomicity is decided, and it is the hardest part of this design — see the failure modes.
The lifecycle, with guards and effects
This is the same order lifecycle as the previous lesson, now carrying the two things that make it operational: what must be true before a move is allowed, and what happens as a consequence.
Read the guards as business rules — because that is what they are — and note that each one appears once. In the six-handler version, three of these guards existed twice with different wording.
| From | On | To | Guard | Effect |
|---|---|---|---|---|
| created | PaymentCaptured | paid | capture amount equals order total | reserve stock; record capture id; email receipt |
| created | Cancel | cancelled | — | void any authorization |
| paid | WarehouseAccepted | picking | reservation not expired | print pick list |
| paid | Cancel | cancelled | not yet accepted by warehouse | refund capture (keyed); release reservation |
| picking | CarrierAccepted | shipped | every non-cancelled line picked | store tracking number; consume reservation; email dispatch note |
| picking | Cancel | cancelled | nothing handed to the carrier yet | refund capture (keyed); release reservation; recall pick list |
| shipped | CarrierConfirmed | delivered | — | open returns window; email delivery confirmation |
- created → shipped — Skipping capture means dispatching goods with no money behind them, and nothing in the system errors — the loss shows up in a stock reconciliation weeks later.
- created → picking — Picking without a reservation lets two orders pick the same unit; the warehouse discovers it physically, at which point one customer's order is already boxed.
- shipped → cancelled — The parcel is with the carrier and the reservation has been consumed. "Cancelling" would release stock that no longer exists, drifting inventory upward on every occurrence. A genuine post-dispatch cancellation is a return, which is a different lifecycle with its own states (Invalid Transitions).
- delivered → shipped — Going backwards re-opens transitions that have already had their effects, so the dispatch email is sent twice and the reservation is consumed twice.
- cancelled → picking — Cancelled released the reservation and refunded the capture; resuming picks stock that is not held and ships goods that are not paid for.
Two of the guards here — "reservation not expired" and "nothing handed to the carrier yet" — were previously unwritten assumptions that happened to hold because of the order in which handlers ran. Writing the table is what surfaced them.
Thirty lines, no library
The machine itself is small, and the smallness matters: a table plus a lookup is something a new engineer reads once and understands, where a state-machine framework is a dependency with its own vocabulary.
The design decision worth copying is the return type. The machine returns the next state and a list of effects; it performs nothing. That single choice is what lets the admin screen ask "what could I do here?" without doing anything, and lets every test run with no stubs at all.
1type Event = 'PaymentCaptured' | 'Cancel' | 'WarehouseAccepted' | 'CarrierAccepted' | 'CarrierConfirmed'2 3type Rule = {4 from: OrderState5 on: Event6 to: OrderState7 guard?: (o: Order) => string | null // null = allowed, string = why not8 effects: (o: Order) => Effect[] // described, not performed9}10 11const RULES: Rule[] = [12 { from: 'created', on: 'PaymentCaptured', to: 'paid',13 guard: (o) => o.captureMatchesTotal() ? null : 'capture-amount-mismatch',14 effects: (o) => [reserveStock(o), emailReceipt(o)] },15 { from: 'picking', on: 'CarrierAccepted', to: 'shipped',16 guard: (o) => o.allActiveLinesPicked() ? null : 'lines-outstanding',17 effects: (o) => [consumeReservation(o), emailDispatch(o)] },18 // ... one row per transition19]20 21export function apply(o: Order, e: Event): Transition | Refusal {22 const rule = RULES.find((r) => r.from === o.state && r.on === e)23 if (!rule) return { refused: 'no-such-transition', from: o.state, on: e }24 const blocked = rule.guard?.(o) ?? null25 if (blocked) return { refused: blocked, from: o.state, on: e }26 return { to: rule.to, effects: rule.effects(o) }27}28 29// the admin screen, for free and never out of date:30export const availableActions = (o: Order) =>31 RULES.filter((r) => r.from === o.state && !r.guard?.(o)).map((r) => r.on)availableActions is the payoff. It is four lines, it cannot disagree with enforcement because it reads the same table, and in the six-handler design it was a hand-maintained list that was wrong roughly once a quarter. Note also that the guard returns a reason rather than a boolean — the refusal reason is what the UI shows and what the logs record (Error Modeling).
Where to put the machine
A lifecycle can be expressed in several places and the choice has consequences that outlast the code. The question is not which is most elegant but which one can be reviewed, tested and changed by the people who own the rules.
The last option is included because it is common and rarely examined. A workflow engine genuinely solves problems — durability, scheduling, visibility — and charges for them in operational surface and vendor coupling.
Who needs to read these rules, how often do they change, and does the lifecycle need to survive a process restart?
when Two states, one or two transitions, no guards worth naming.
cost Nothing now. The rules are unreadable as a whole from the moment there are three handlers, and the drift is silent.
when Up to a few dozen transitions, changed by engineers, evaluated in-process. The recommendation for most business objects.
cost Indirection and the discipline of returning effects rather than performing them.
when The transition must be enforced against paths outside the application, including bulk SQL.
cost The rule lives twice and must be kept in sync; guards involving anything but the row itself are awkward or impossible (Database Constraints).
when Transitions span days, involve external waits, and must survive restarts with visibility for ops.
cost An operational dependency, a deployment story, vendor coupling, and rules expressed in someone else's vocabulary. Genuinely right for long-running fulfilment; heavy for an order that lives for minutes (What a Framework Charges).
when The audit trail is a first-class requirement and the state is a fold over events.
cost Every read replays or reads a projection, and schema evolution of old events becomes a permanent obligation (Event Sourcing in Architecture has the full picture).
How to build it
Most important first.
- Write the table as data: from, to, event, guard, effects. Data can be printed, diffed, reviewed by a non-engineer and used to generate the admin screen (Docs Close to Code).
- Make guards pure predicates over the order plus explicit inputs. A guard that queries a database is a guard you cannot test and cannot evaluate speculatively for the "what actions are available" screen (Purity and Testing).
- Return effects as values rather than performing them inside the machine. The machine says "refund this capture, send this email"; the shell does it. This is what makes the whole lifecycle testable without stubs.
- Write the state before performing external effects, and make each effect idempotent and keyed, so a crash between them is recoverable by re-running (Idempotency by Design).
- Derive the available-actions list from the table, so the admin UI and the enforcement can never disagree.
- Keep the machine to one lifecycle. Two interleaved lifecycles in one enum is the most common way these tables become unreadable (Boolean Flag Explosion).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Before: adding a transition costs an edit in one handler, a guard duplicated from another, and an update to a hand-maintained UI list that will be forgotten.
- After: adding a transition is one row in the table. The UI updates itself, the tests enumerate it automatically, and no handler changes.
- Adding a state: one entry plus its transitions. Every switch over states becomes exhaustive-checked by the compiler in a language that supports it, so the cases you must handle are listed for you rather than discovered in production (Making Illegal States Unrepresentable).
- The change that is still expensive: changing what an effect does when it half-fails. Recovery semantics are not in the table and cannot be — they are an operational design that the table assumes rather than defines.
- A table is indirection: a reader tracing "what happens when we ship" goes to the table, then to the effect handler, where before it was one function.
- Returning effects as values is a real discipline and awkward in codebases where everything else performs I/O inline; adopting it in one module makes that module different from its neighbours.
- The generality invites over-modelling — a machine for a two-state lifecycle costs more than the
ifit replaces (Over-Design and Under-Design).
What can go wrong
- The state is written and an effect then fails, so the order is
shippedwith no customer notification. This is the central failure of every state machine that touches the outside world, and it is solved by idempotent re-runs, not by transactions (Partial Failure). - Guards start performing queries, then start caching, and eventually the guard for "can ship" has a side effect. Everything downstream — speculative evaluation, the admin screen, the tests — breaks quietly.
- The table grows a
*wildcard transition "for admin overrides", and within a year every forbidden transition is reachable through it (Invalid Transitions). - The mitigation fails too: wrapping the transition and its effects in a database transaction appears to fix atomicity but cannot roll back an email or an external capture, and holding the transaction open across a network call creates lock contention that takes the system down under load (External Calls Inside a Transaction).
- Handlers depend on the machine; the machine depends on nothing but the domain types. The dependency arrow points strictly inward, which is why the machine is testable with no infrastructure (Dependency Direction).
- The admin UI depends on the table's derived output rather than on its own copy of the rules, which removes an entire class of drift.
- The effect executor depends on external systems, and it is the only part of this design that does (Volatile Dependencies).
- "We need a state machine library." Almost never at this scale. A table of rows and a function that looks up the row is thirty lines and has no dependency, no DSL to learn and no version to upgrade (Library or Framework).
- "The machine should perform the effects." Then it cannot be tested without stubs and cannot be evaluated speculatively to answer "what can I do here?". Returning effects is the design decision that gives the pattern most of its value.
- "Guards can query whatever they need." A guard that hits the database cannot be evaluated for a list of orders, which is exactly what the admin screen needs. Pass the facts in (Cost-Aware Interfaces).
- "State machines are for protocols and parsers." They are for anything whose legal operations depend on where it is in its life, which includes most business objects, most background jobs and most deployments.
- shotgun-surgery
- divergent-change
Testing it, and how it ages
- Enumerate the table: for every state and every event, assert that the outcome is either a defined transition or a defined rejection. This one test replaces dozens of example tests and catches every gap (Property-Based Testing).
- Test guards as pure functions with no machine involved — they are the business rules and they deserve direct tests.
- Test that effects are returned rather than performed, by running a full transition with no infrastructure at all and asserting on the returned effect list.
- Test re-execution: apply the same transition twice and assert the effects are not duplicated, because a retry after a partial failure will do exactly this (Idempotency by Design).
- The table grows a row at a time, which is the healthiest possible growth shape: additive, reviewable, and with no change to existing rows.
- When the table exceeds roughly two dozen rows it usually contains two lifecycles, and splitting into two machines with a small coupling between them is the standard fix.
- What eventually forces a rethink: transitions triggered by time or by other aggregates rather than by events on this one. At that point the machine needs a scheduler and a way to receive external events, which is a larger design (Event-Driven Backends in Backend).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThe idea that legal moves belong in one reviewable place is independent of language; only how much the compiler can check about it varies.
- LANGUAGE-SPECIFICWith sum types and exhaustive matching — Rust, TypeScript with a discriminated union, Kotlin sealed classes — a missing case in a switch is a compile error, so adding a state is safe by construction. With a string status and a
switchwith adefault, the same omission compiles and silently does nothing, so the design needs an enumerating test to recover the guarantee. - SIMPLIFIEDThe table here is a flat machine. Real lifecycles often want hierarchy (a
shippedsuperstate with substates) and orthogonal regions (payment state alongside fulfilment state); those are well-understood extensions, deliberately omitted so the core idea stays readable, and reaching for them too early is its own failure (Boolean Flag Explosion covers the orthogonal case).
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — System Design — a lifecycle whose transitions are driven by other services becomes a choreography or an orchestration problem, and the choice between them is a system-level decision with very different failure modes.