StateGENERALLANGUAGE-SPECIFICSIMPLIFIED

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.

The question

How do I express a lifecycle so that the legal moves, their preconditions and their side effects are all visible in one place?

The requirement

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.

The obvious build

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.

Why it breaks

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).

How it breaks as requirements change
  • 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 checks pickedCount === 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.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

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.

Constraints
  • 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.
Invariants
  • 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.

Responsibilities
  • 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).
Boundaries
  • 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.

Order fulfilment with guards and effects
createdpaidpickingshippeddelivered ·cancelled ·
FromOnToGuardEffect
createdPaymentCapturedpaidcapture amount equals order totalreserve stock; record capture id; email receipt
createdCancelcancelledvoid any authorization
paidWarehouseAcceptedpickingreservation not expiredprint pick list
paidCancelcancellednot yet accepted by warehouserefund capture (keyed); release reservation
pickingCarrierAcceptedshippedevery non-cancelled line pickedstore tracking number; consume reservation; email dispatch note
pickingCancelcancellednothing handed to the carrier yetrefund capture (keyed); release reservation; recall pick list
shippedCarrierConfirmeddeliveredopen returns window; email delivery confirmation
must be impossible
  • created → shippedSkipping 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 → pickingPicking 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 → cancelledThe 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 → shippedGoing backwards re-opens transitions that have already had their effects, so the dispatch email is sent twice and the reservation is consumed twice.
  • cancelled → pickingCancelled 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.

The table is the design; the engine is boilerplate
1type Event = 'PaymentCaptured' | 'Cancel' | 'WarehouseAccepted' | 'CarrierAccepted' | 'CarrierConfirmed'
2
3type Rule = {
4 from: OrderState
5 on: Event
6 to: OrderState
7 guard?: (o: Order) => string | null // null = allowed, string = why not
8 effects: (o: Order) => Effect[] // described, not performed
9}
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 transition
19]
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) ?? null
25 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.

Where does the lifecycle live?

Who needs to read these rules, how often do they change, and does the lifecycle need to survive a process restart?

Conditionals in each handler

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.

A transition table in code

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.

Database constraints or triggers

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).

A durable workflow engine

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).

Derived from an event log

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.

Cost of the next change
  • 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.
What the recommended approach costs
  • 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 if it replaces (Over-Design and Under-Design).

What can go wrong

Failure modes
  • The state is written and an effect then fails, so the order is shipped with 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).
Dependencies, and their direction
  • 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).
Misreads
  • "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.
Smells this explains
  • shotgun-surgery
  • divergent-change

Testing it, and how it ages

What to test, and at which boundary
  • 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).
How this design ages
  • 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 switch with a default, 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 shipped superstate 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.

Architectureevent-sourcing
Distributed Systemsidempotent-operations
Domains that do not exist yet
  • 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.