PatternsLANGUAGE-SPECIFICDOMAIN-SPECIFICCONTESTED

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.

The question

My order class is full of if (status === ...). Is a class per state the fix, or is the fix a transition table?

The requirement

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.

The obvious build

Keep a status field and check it where it matters. Every method starts with a guard, and the guards are easy to read individually.

Why it breaks

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

How it breaks as requirements change
  • 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_refunded means 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).
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
Invariants
  • 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.

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

Order lifecycle
draftpending_paymentpaidshippeddelivered ·cancelled ·
FromOnToGuardEffect
draftsubmitpending_paymentat least one line and stock availablereserve stock
pending_paymentauthorisation capturedpaidrecord charge id
pending_paymentcancel or timeoutcancelledrelease reservation
paidcarrier acceptsshippedall lines allocatedrecord tracking number
paidcancelcancellednot yet handed to carrierrefund the capture
shippedcarrier confirmsdelivered
must be impossible
  • delivered → pending_paymentReopening 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 → cancelledGoods 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 → paidSkips 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 → anyTerminal 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).

Same lifecycle, two homes
A class per state, for a permission difference
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.
A table, plus types where the data differs
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.

smellState assigned outside a transition

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.

when this is fine Genuinely fine in exactly two places, both of which should be explicit and rare: a data-repair script run under supervision with an audit entry written by hand, and the deserialization path that reconstructs a stored order — which must be able to produce any state, because states that exist in the database exist whether the machine likes them or not (Data Migration). Both should be the only code outside the transition module that can touch the field, and both should say so in a comment that names this constraint.

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.

Cost of the next change
  • 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).
What the recommended approach costs
  • 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

Failure modes
  • 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.
Dependencies, and their direction
  • 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).
Misreads
  • "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).
Smells this explains
  • boolean-flag-explosion
  • shotgun-surgery

Testing it, and how it ages

What to test, and at which boundary
  • 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.
How this design ages
  • 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.

Domains that do not exist yet
  • 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.