Back to the path
Level 3 advanced 26 min #state-pattern#design-patterns#behavioral-patterns#lld#solid#state-machine

The State Pattern: Replacing Conditional Sprawl with Behavioral Objects

A rigorous, interview-grade treatment of the State pattern: how delegating to state objects eliminates sprawling conditionals, modeled through an order-lifecycle example in TypeScript, Python, and C++ with a fail-closed base state, plus the critical distinction from Strategy and the SOLID tradeoffs interviewers probe.

Intent

Allow an object to alter its behavior when its internal state changes — the object appears to change its class. It does this by delegating state-dependent behavior to a family of state objects, each encapsulating the behavior (and the legal transitions) for one state.

If you remember one sentence for the interview: State turns the values of a state variable into polymorphic objects, so the switch on that variable disappears.

The Problem It Solves

Consider an Order in an e-commerce system. Its lifecycle is CREATED → PAID → SHIPPED → DELIVERED, with side-branches CANCELLED and REFUNDED. Each operation (pay, ship, cancel, refund) is only legal in some states. The naive implementation is a forest of conditionals:

class Order {
  status: string;  // "CREATED" | "PAID" | "SHIPPED" | ...

  pay() {
    if (this.status === "CREATED") { /* charge card */ this.status = "PAID"; }
    else if (this.status === "PAID") throw "already paid";
    else if (this.status === "CANCELLED") throw "cannot pay cancelled order";
    else if (this.status === "SHIPPED") throw "already shipped";
    // ... and so on for every status
  }

  ship() {
    if (this.status === "PAID") { /* book courier */ this.status = "SHIPPED"; }
    else if (this.status === "CREATED") throw "pay first";
    // ... repeat the whole status ladder
  }
  // cancel(), refund(), deliver() each repeat the ladder
}

This has well-known pathologies:

  • The conditional matrix explodes. Every new operation must enumerate every state, and every new state must be wired into every operation. It is O(states × operations) of branching, all in one class.
  • Transition rules are scattered. “You can only refund a delivered order within 30 days” lives inside refund(), while “shipping requires payment” lives inside ship(). There is no single place to see or validate the state machine.
  • It violates Open/Closed. Adding a RETURNED state means editing every method — high risk of missing a branch.
  • It is hard to test. Each method has a combinatorial number of paths through a single mega-function.

State pattern replaces the status string with a state object. Each operation simply calls this.state.pay(this), and the current state object decides what happens — including which state comes next.

Key idea

The defining move of State: the conditional that branches on a mode/status field is replaced by polymorphic dispatch. If you find yourself writing the same switch (this.status) in more than one method, you are hand-rolling a state machine that State formalizes.

Structure (ASCII UML)

        ┌──────────────────────────────────┐         ┌──────────────────────────┐
        │             Context              │         │      <<abstract>>        │
        │              (Order)             │         │        OrderState        │
        ├──────────────────────────────────┤         ├──────────────────────────┤
        │ - state: OrderState              │◇───────▶│ + pay(ctx): void   {thr} │
        ├──────────────────────────────────┤  holds  │ + ship(ctx): void  {thr} │
        │ + pay()    { state.pay(this) }   │         │ + cancel(ctx): void{thr} │
        │ + ship()   { state.ship(this) }  │         │ + name(): string         │
        │ + cancel() { state.cancel(this) }│         └────────────┬─────────────┘
        │ + setState(s: OrderState)        │                      │ extends
        └──────────────────────────────────┘                      │
                  ┌───────────────────────┬───────────────────────┼────────────────────┐
                  ▼                       ▼                        ▼                    ▼
        ┌──────────────────┐   ┌──────────────────┐  ┌──────────────────┐ ┌──────────────────┐
        │  CreatedState    │   │   PaidState      │  │  ShippedState    │ │ CancelledState   │
        ├──────────────────┤   ├──────────────────┤  ├──────────────────┤ ├──────────────────┤
        │ pay → PaidState  │   │ ship→ShippedState│  │ (overrides none; │ │ cancel → no-op   │
        │ cancel→Cancelled │   │ cancel→Cancelled │  │  all ops inherit │ │ (idempotent);    │
        │ (ship inherits   │   │ (pay inherits    │  │  the throwing    │ │ pay/ship inherit │
        │  throw default)  │   │  throw default)  │  │  default)        │ │  throw default)  │
        └──────────────────┘   └──────────────────┘  └──────────────────┘ └──────────────────┘

The Context delegates every request to the current State object and exposes setState so states can transition it. The abstract base state throws on every operation by default; each Concrete State overrides only the operations that are legal in that state. Everything you forget is therefore rejected automatically.

Participants

ParticipantRole
Context (Order)Holds a reference to the current OrderState; forwards client requests to it; exposes setState. The client only ever talks to Context.
State (abstract base, OrderState)Declares one method per state-dependent operation (pay, ship, cancel, …) and supplies a fail-closed default that throws.
Concrete States (CreatedState, PaidState, …)Override the behavior and the outgoing transitions for the operations legal in that state. They call ctx.setState(next) to advance the machine.

Two design decisions you must be ready to defend:

  1. Who decides transitions? Either the concrete states (decentralized, common) or the Context (centralized). Decentralized states have explicit knowledge of their successors, which couples them but keeps the logic local. Mention this tradeoff explicitly in interviews.
  2. Are states shared instances or per-context instances? If a state object holds no per-order data it is a flyweight and can be shared (a singleton). If it carries data (e.g., a retry counter scoped to one order), instantiate it per Context. The samples below instantiate fresh objects on each transition for clarity; the Flyweight optimization is a one-line change once you confirm the state is data-free.

Real-World Example: Order Lifecycle (Fail-Closed)

Below, Order is the Context. The base OrderState throws on every operation; each concrete state overrides only the legal ones. Notice there is no if (status == ...) anywhere, and there is no per-state hand-written illegal(...) for every disallowed op — illegality is the default.

Tip

Why fail-closed beats fail-open. If each concrete state had to hand-write a rejection for every illegal operation, forgetting one would silently allow an illegal transition (fail-open). With a throwing base class, forgetting an override means the operation is rejected (fail-closed). You get safety from omission rather than from vigilance.

abstract class OrderState {
abstract readonly name: string;
// Fail-closed defaults: anything not overridden is illegal.
pay(_o: Order): void    { this.illegal("pay"); }
ship(_o: Order): void   { this.illegal("ship"); }
cancel(_o: Order): void { this.illegal("cancel"); }
protected illegal(op: string): never {
  throw new Error(`Cannot ${op} an order in ${this.name} state`);
}
}

class Order {
private state: OrderState = new CreatedState();
constructor(public readonly id: string, public readonly cents: number) {}

setState(s: OrderState) { this.state = s; }
get status() { return this.state.name; }

pay()    { this.state.pay(this); }
ship()   { this.state.ship(this); }
cancel() { this.state.cancel(this); }
}

class CreatedState extends OrderState {
readonly name = "CREATED";
override pay(o: Order)    { /* charge gateway */ o.setState(new PaidState()); }
override cancel(o: Order) { o.setState(new CancelledState()); }
// ship() inherits the throwing default.
}

class PaidState extends OrderState {
readonly name = "PAID";
override ship(o: Order)   { /* book courier */ o.setState(new ShippedState()); }
override cancel(o: Order) { /* issue refund */ o.setState(new CancelledState()); }
// pay() inherits the throwing default.
}

class ShippedState extends OrderState {
readonly name = "SHIPPED";
// Every op inherits the throwing default — in-flight orders are frozen.
}

class CancelledState extends OrderState {
readonly name = "CANCELLED";
override cancel(_o: Order) { /* idempotent no-op */ }
// pay() and ship() inherit the throwing default.
}

const o = new Order("A1", 4999);
o.pay();   // CREATED -> PAID
o.ship();  // PAID -> SHIPPED
// o.cancel(); // throws: Cannot cancel an order in SHIPPED state

Notice what is gone: there is no if (status == ...) anywhere, and there is no per-state boilerplate enumerating illegal ops. Adding a RETURNED state is a new subclass plus edits only to the states that can transition into it — the rest of the system is untouched. That is Open/Closed in action.

Watch out

On instance sharing. The samples create a fresh state object on every transition (new PaidState(), make_unique<PaidState>()). That is the simplest correct choice. Because these states hold no per-order data, you may make them shared singletons (a module-level instance in Python, a static instance or shared_ptr registry in C++, a static readonly field in TS) to avoid the churn — a Flyweight optimization. Do this only after confirming the state truly carries no per-context mutable data, or two orders will alias the same object and corrupt each other.

The classic siblings: vending machine & TCP

The same skeleton models a vending machine (NoCoin → HasCoin → Dispensing → SoldOut, where insertCoin/pressButton/dispense behave differently per state) and a TCP connection (Closed → Listen → Established → ..., the original GoF example, where open/close/acknowledge are state-dependent). They are interchangeable in structure; pick whichever the interviewer’s domain suggests.

When To Use

  • The object’s behavior depends on its state, and it must change behavior at runtime as state changes.
  • Operations have large, repetitive multipart conditionals on the same state field.
  • You have an explicit state machine with well-defined transitions worth making first-class (and possibly visualizable/testable in isolation).

When NOT To Use (Pitfalls & Over-use)

Common pitfall

Don’t reach for State when there are only two states and one or two operations. A boolean flag and a single if is clearer than five classes. State pays off when the conditional matrix is large; below that threshold it is ceremony. Interviewers reward knowing when not to apply a pattern as much as when to.

  • Class explosion. N states with real behavior means N classes. For a 12-state workflow this is a lot of files. Consider a table-driven state machine (a Map<(State, Event) -> State>) when transitions vastly outnumber per-state behavior. State pattern shines when each state has rich behavior, not merely a next-state mapping.
  • Transition logic smeared across states. Decentralized transitions make it hard to see the whole machine. If auditability matters (regulated workflows), centralize transitions in the Context or a transition table.
  • Shared mutable data. When several states need the same context data, you push it onto the Context and pass ctx everywhere — watch for the Context becoming a god object.
  • Concurrency. The state field is mutable; concurrent pay()/cancel() can race. Guard transitions with a lock (or a compare-and-set on the state reference). The C++ example would need a std::mutex around setState and the dispatch methods for thread safety.

Relationship to SOLID

  • Open/Closed (the big win): new states are new classes; existing states and the Context are not modified.
  • Single Responsibility: each state class owns the behavior of exactly one state, instead of one class owning all states.
  • Liskov — both directions matter. Every concrete state must honor the OrderState contract. There are two ways to break it, and a sharp interviewer will name both: (a) a state that silently does nothing where the contract implies an effect can mislead callers; (b) a state that throws where the base contract promises success is itself an LSP violation. We resolve this honestly by making the base contract say “each operation is only valid in legal states, and throws otherwise.” Under that contract, throwing-by-default is conformant, not a violation — the substitutability holds because every subtype obeys the same “throw when illegal” rule. State the contract first, then the throw is principled rather than ad hoc.
  • The cost side: you trade fewer conditionals for more types and more indirection. Be honest about that in interviews; it’s a deliberate complexity trade, not a free win.

State vs Strategy — Same UML, Opposite Intent

This is the question interviewers love, because the class diagrams are structurally identical: a Context holding a pluggable interface with concrete implementations. The difference is entirely in intent and dynamics.

DimensionStateStrategy
What variesWhich behavior, as a function of the object’s internal lifecycle phaseHow one fixed task is performed (the algorithm)
Who picks the implementationThe states themselves drive transitions (or the Context per the state machine) — chosen internallyThe client/Context picks the strategy externally and injects it
Awareness of siblingsA state typically knows its successors and self-transitions (ctx.setState(next))A strategy is unaware of other strategies; it never swaps itself
Changes over an object’s lifeYes — it flips repeatedly as events occur (CREATED → PAID → SHIPPED …)Usually set once and held; swapped only if the client decides to
Coupling to ContextOften back-references the Context to transition and read shared dataUsually takes only the inputs it needs; minimal Context coupling
Intent (GoF)“Behavior depends on state; appear to change class""Encapsulate interchangeable algorithms; make them swappable”

One-liner to say out loud: “Strategy is chosen from outside and is ignorant of its siblings; State drives itself from inside and knows what it becomes next.”

Confused with other patterns, too

  • vs. Command: Command encapsulates a request as an object (to queue/undo/log); State encapsulates a mode of being. A Command does one thing when invoked; a State changes what every operation does.
  • vs. a finite-state-machine table: A transition table (Map<(state, event), state>) is the data-only sibling. Prefer State when states have rich behavior; prefer the table when they are mostly a next-state mapping and you want the whole machine auditable in one place.
Key idea

In an interview, when asked “State or Strategy?” do not answer with the UML (it’s identical). Answer with dynamics: self-driven internal transitions and successor-awareness (State) versus externally injected, sibling-ignorant algorithms (Strategy).

Assessment

Pass mark: 70% on the concept questions unlocks the next lesson.

1. What is the single defining move of the State pattern, as opposed to incidental properties it shares with other patterns?

2. Which statements correctly distinguish State from Strategy? (Select all that apply.) (select all)

3. Why does this lesson favor a base state whose every operation throws, with concrete states overriding only the legal ones?

4. You are reviewing a State implementation for a 12-step regulated approval workflow that must be fully auditable, where most states only map an event to a next state with little per-state behavior. Which critiques/decisions are sound? (Select all that apply.) (select all)

Design problem 5

Design a State-pattern model for a document review workflow with states DRAFT, IN_REVIEW, APPROVED, REJECTED, and ARCHIVED. Operations are submit(), approve(), reject(), reviseAfterReject(), and archive(). Rules: a DRAFT can be submitted (→IN_REVIEW) or archived; IN_REVIEW can be approved (→APPROVED) or rejected (→REJECTED); REJECTED can be revised (→DRAFT) or archived; APPROVED can be archived; ARCHIVED is terminal (all ops illegal). Specify the base-state contract, show how illegal operations are handled fail-closed, decide whether states should be shared singletons, and pick where transitions live. Provide a code sketch in one language of your choice.