State Pattern
Behaviour that varies by lifecycle state, held in a type per state. Often the right instinct — and an explicit state machine is usually the clearer way to satisfy it.
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.
My order class is full of if (status === ...). Is a class per state the fix, or is the fix a transition table?
An order moves through draft, pending payment, paid, shipped, delivered and cancelled. What you may do to it — edit lines, refund, cancel, ship — depends on which state it is in, and the rules keep changing.
Keep a status field and check it where it matters. Every method starts with a guard, and the guards are easy to read individually.
The guards are duplicated and drift. cancel() checks three states, refund() checks four, and nobody can say which set is correct because there is no single statement of the rules (Duplicate Knowledge).
- The guards are duplicated and drift.
cancel()checks three states,refund()checks four, and nobody can say which set is correct because there is no single statement of the rules (Duplicate Knowledge). - New states multiply guards combinatorially: adding
partially_refundedmeans revisiting every method and deciding, one at a time, whether it applies (Shotgun Surgery). - Illegal transitions are reachable. Nothing prevents assigning
status = "paid"directly, and eventually something does (Invalid Transitions). - The rules exist only as scattered conditionals, so the question auditors ask — what transitions are possible? — has no answer short of reading every method (Boolean Flag Explosion).
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.
- The status is persisted as a string and appears in reports and an external API, so state names are part of a contract (Enum Evolution: The New Value That Broke Old Clients).
- Two engineers have added a
cancelledcheck in the wrong place this quarter, causing incidents (Invalid Transitions). - Auditors need to know when each transition happened and who caused it (Resources Have State Machines).
- A delivered order can never return to pending. Terminal states are terminal (State Machines).
- Every transition is recorded with actor and timestamp; there is no way to change state without leaving a trace (Explicit State).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- One place owns the legal transitions: which state may become which, under what guard, with what effect (State Machines).
- One place owns what is permitted in each state — and it may or may not be the same place, which is the actual design decision here.
- Persistence owns storing the state name and the transition history, and owns nothing about the rules (State Ownership).
- The state boundary must be total: no code outside it may assign the status field. A private setter plus a transition method is the minimum (Enforcing Invariants).
- State names are a published contract because they leave the system in APIs and reports; renaming one is an API change (Resources Have State Machines).
- The transition table is the seam that changes when the lifecycle changes, and keeping it as data is what makes that change reviewable (Explicit State).
Write the machine down first
Before choosing where the behaviour lives, state what the lifecycle actually is. Most of the incidents this requirement mentions come from rules that were never written anywhere — they existed as guards, and two guards disagreed.
The forbidden list is the part that scattered conditionals can never express. A guard says what is allowed here; only an explicit machine says what must never happen anywhere (Invalid Transitions).
| From | On | To | Guard | Effect |
|---|---|---|---|---|
| draft | submit | pending_payment | at least one line and stock available | reserve stock |
| pending_payment | authorisation captured | paid | — | record charge id |
| pending_payment | cancel or timeout | cancelled | — | release reservation |
| paid | carrier accepts | shipped | all lines allocated | record tracking number |
| paid | cancel | cancelled | not yet handed to carrier | refund the capture |
| shipped | carrier confirms | delivered | — | — |
- delivered → pending_payment — Reopening a completed order would re-reserve stock that was already consumed and re-charge a customer who already paid. Returns are a separate process with their own lifecycle, not a reversal of this one.
- shipped → cancelled — Goods are with the carrier; "cancelled" would assert a refund was made and stock returned, neither of which is true. The honest model is a return, which is a different aggregate (Aggregates).
- draft → paid — Skips reservation, so stock is sold twice under concurrency. This is the transition an admin tool will eventually try to make and must be refused (The Lost Update, Step by Step).
- cancelled → any — Terminal means terminal. A re-order is a new order with a new id, so that the audit trail of the first stays true (Stable Identifiers).
Six states, six transitions, four forbidden. This whole table fits on a screen, can be reviewed by the people who own the rules, and is exhaustively testable in a loop — which is why it comes before any decision about classes.
Table or class per state
Both columns enforce the same rules. The difference is where the rules live and what they can express: a table states the whole lifecycle in one place, a sum type makes the *data* differences unrepresentable rather than merely checked.
The test for which you need is the data. If pending orders carry a payment intent and shipped ones carry a tracking number, the type version deletes a whole category of bug — order.trackingNumber cannot be undefined because it does not exist on the other states. If every state carries the same fields, the classes are six files answering a question the table already answered (Making Illegal States Unrepresentable).
abstract class OrderState {
canCancel(): boolean; canShip(): boolean; canEdit(): boolean
}
class Draft extends OrderState { canCancel() { return true } /* ... */ }
class PendingPayment extends OrderState { /* same fields as Draft */ }
class Paid extends OrderState { }
class Shipped extends OrderState { }
class Delivered extends OrderState { } // all four methods return false
class Cancelled extends OrderState { } // identical to Delivered
// Six files. Two are identical. "What can happen to an order"
// requires reading all six, and no file states the transitions.type OrderState =
| { name: 'draft'; lines: Line[] }
| { name: 'pending_payment'; intent: PaymentIntentId }
| { name: 'paid'; charge: ChargeId }
| { name: 'shipped'; tracking: TrackingNumber }
| { name: 'delivered' } | { name: 'cancelled'; refund?: RefundId }
const TRANSITIONS: Transition[] = [
{ from: 'draft', on: 'submit', to: 'pending_payment', guard: hasStock },
// ...six rows, and the forbidden ones simply absent
]
export const apply = (o: Order, e: Event): Result<Order, Illegal> => ...The table states the entire lifecycle in one reviewable place, so the question "can a shipped order be cancelled" has one address instead of six. The sum type does the work the classes were reaching for and does it better: tracking exists only on shipped, so reading it elsewhere is a compile error rather than an undefined at runtime. The class version enforces permissions and nothing about data, which is why four of its six files ended up identical (Explicit State).
The failure the pattern does not prevent
Everything above assumes state can only change through a transition. In practice it usually cannot be assumed, and this is the single most common way a carefully-built state machine turns out to guarantee nothing.
looks like order.status = "paid" in a repository, an admin action, a migration or a test helper — anywhere other than the transition function. Often justified as fixing bad data.
suggests The state machine is advisory. Every rule, guard and forbidden transition it declares is enforced only for the callers who chose to go through it, which is a documentation-level guarantee wearing a type-level costume (Invariant Leaks).
fix Make the field private with no setter and expose only transitions; where the language cannot enforce it, an architecture test that fails the build on any assignment outside the module is the cheap equivalent (What to Automate Out of Review). For the repair case, add an explicit forceState(reason, actor) that writes the audit record — a supervised hole is far better than an unsupervised one.
How to build it
Most important first.
- Write the state machine down first, as a table or a typed structure: states, transitions, guards, and explicitly the transitions that must not exist (State Machines).
- Make transitions the only way to change state, returning a result for illegal ones rather than throwing, because a user asking to cancel a shipped order is an expected outcome (Result Types).
- Only then ask whether behaviour differs enough per state to justify a type per state. If the difference is "which operations are allowed", a table answers it with far less machinery.
- Reach for state classes when each state carries genuinely different *data* as well as different behaviour — a pending order has a payment intent, a shipped one has a tracking number — because that is when a sum type makes illegal combinations unrepresentable (Making Illegal States Unrepresentable).
- Keep the transition log as an append-only record, so the audit requirement is satisfied by the mechanism rather than by remembering to log (Stable Identifiers).
- Do not create a state class per status if two of the six behave identically. Two classes with the same body is the pattern applied by count rather than by variation (Pattern Overuse).
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.
- Named change — "add a partially-refunded state": with an explicit table, one state, its permitted transitions, and a compiler or test that names every place that must handle it; with scattered guards, a review of every method that mentions status.
- Named change — "cancelling a paid order must now trigger a refund": with a table, one transition's effect; with state classes, one class; with guards, wherever cancellation happens to be implemented.
- Named change — "auditors want the transition history": with a table plus a transition method, near-free because there is one chokepoint; with guards, a logging call at every assignment site, and the ones you miss are invisible.
- What the *state pattern specifically* makes cheaper over a plain table: nothing, unless states carry different data or different behaviour beyond permission. That is the honest test, and for most order lifecycles it fails (State Machines).
- A type per state means the lifecycle is spread over six files, and reading "what can happen to an order" requires all six — a table shows it on one screen.
- Sum types make illegal states unrepresentable and make every function that handles orders match exhaustively, which is more code at every call site (Making Illegal States Unrepresentable).
- An explicit machine is stricter than reality: real businesses want exceptions, and each exception is either a new transition or a hole in the model.
What can go wrong
- A state class per status, four of which are identical, so the pattern has multiplied files without capturing any variation.
- The state object holds a reference back to the context and mutates it, so behaviour is spread across two objects and reasoning requires both (Dependency Cycles).
- Transitions bypassed by persistence: a migration, an admin tool or a repository sets the status column directly, and every guarantee in this lesson evaporates (Invariant Leaks).
- The mitigation fails too: making the state machine total and explicit produces a table with thirty entries that is genuinely hard to read, and people start adding "temporary" direct assignments around it.
- The transition table depends on nothing. That is what makes it testable exhaustively and readable by a non-engineer, which matters when auditors ask (A Deterministic Core).
- State classes depend on the domain types they carry, and each pulls in its own dependencies — which is an advantage when they differ and pure overhead when they do not.
- Callers depend on the operation, not the state:
order.cancel(actor)returns a result, and the caller never branches on status (Local Reasoning).
- "Any status field should become state classes." Most status fields want a transition table and a guard method. Classes are for when the states differ in data and behaviour, not in permissions (State Machines).
- "The state pattern prevents illegal transitions." Only if the state is unreachable except through transitions. With a public setter or a repository that writes the column, it prevents nothing (Invariant Leaks).
- "A state machine is over-engineering for six states." Six states with fifteen scattered guards is more machinery than a fifteen-row table, and less visible (Essential and Accidental Complexity).
- "State classes and a state machine are alternatives." They are different questions: the machine says what transitions exist, the classes say where behaviour lives. You can and often should have the machine without the classes (Explicit State).
- boolean-flag-explosion
- shotgun-surgery
Testing it, and how it ages
- Exhaustive transition tests: for every state and every event, assert allowed or rejected. This is a loop over the table, not one test per case (Property-Based Testing).
- Explicitly test the forbidden transitions, since they are the invariant and are otherwise untested by definition (Invalid Transitions).
- Test that no code path assigns the state field outside a transition — in practice, an architecture test or a private setter (What to Automate Out of Review).
- Test the audit record is written on every transition, including rejected ones if the audit requires attempted transitions.
- Lifecycles gain states forever and rarely lose them. Anything that makes adding a state a local change pays continuously (Extensibility).
- State names outlive their meaning because they are in reports and APIs, so expect to carry a state that means something slightly different from its name (Deprecation).
- The common trajectory is guards, then a table, then state-carrying types once the data genuinely differs — and skipping the middle step is what produces six classes for a permission matrix.
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.
- LANGUAGE-SPECIFICIn a language with sum types and exhaustive matching (Rust, Kotlin, TypeScript discriminated unions, Scala, Swift) states carrying different data make illegal combinations a compile error, and the pattern is a data type rather than a class hierarchy. Without them the same design needs a runtime guard and a test, so the argument for the class-based version is stronger and its guarantees are weaker (Making Illegal States Unrepresentable).
- DOMAIN-SPECIFICWorth it where the lifecycle is regulated or contested — orders, payments, claims, deployments, subscriptions — and overkill where the "states" are really a two-value flag with no transition rules beyond "it can flip".
- CONTESTEDThe strongest case for classes over a table: when each state genuinely carries different data, a sum type makes whole classes of bug unrepresentable rather than merely tested, which no table achieves. The strongest case against: a table can be read by a product manager and an auditor, reviewed as a single diff, and generated into a diagram — and most order lifecycles differ in permissions rather than data, where the classes add six files and answer no question the table did not.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — an explicit transition table is exhaustively testable in a loop, which is a rare case where full coverage of a business rule is both achievable and meaningful.