Engineering Atlas › Low-Level Design

State pattern

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The problem

A publishing tool moves articles through a review workflow: an author writes a draft, submits it, two editors approve it, and the author publishes it. Editors can reject at any point before publication, which sends the article back to draft. Published articles can be archived. The first version stored a status string and checked it everywhere:

def approve(self, user):
    if self.status != "review":
        raise ValueError("cannot approve in " + self.status)
    if user == self.author:
        raise ValueError("author cannot approve")
    self.approvals.add(user)
    if len(self.approvals) >= 2:
        self.status = "approved"

Every method starts with a check like that, and each check encodes part of a state diagram that nobody can see in one place. Adding a "scheduled" state between approved and published means auditing every method. The bug that eventually shipped was predictable: a new API endpoint for bulk editing forgot the status check and let authors change the text of approved articles after review.

One class per state

State moves the per-mode behaviour into objects:

class Review(DocState):
    name = "review"

    def approve(self, doc, user):
        if user == doc.author:
            raise ValueError("author cannot approve")
        doc.approvals.append(user)
        if len(doc.approvals) >= doc.required:
            doc.transition(APPROVED)

The Document (the context) forwards every call to its current state: def approve(self, user): self.state.approve(self, user). A base class implements every operation as "cannot do this in this state", and each concrete state overrides only what it allows. Open Review and you see everything a document in review can do. The illegal combinations do not need code at all; they inherit the refusal.

Where the data lives

States should hold behaviour, not data. The text, author, approvals and history stay on the document. That has two benefits. Transitions never copy data between objects, so nothing is lost when the state changes. And states with no fields can be shared: one REVIEW instance serves every document in the process, the flyweight trick the vending machine design in this course uses as well.

Who triggers transitions

In the classic form, states decide the next state themselves (doc.transition(APPROVED)). That keeps each transition next to the rule that causes it, at the cost of states knowing about each other. The alternative is a central transition table, {("review", "reject"): "draft", ...}, consulted by the context. Tables are easy to draw, validate and generate diagrams from; classes are better when each transition has real logic (counting approvals, checking guards, sending notifications). Many production systems combine them: a table for which transitions exist, and per-state code for what happens.

State versus Strategy

The two have the same structure. The difference is who swaps the object. A strategy is chosen from outside, usually once, and does not know its siblings. A state is replaced by the states themselves as events arrive, and the set of states forms a closed graph. If your "strategies" start calling context.set_strategy(...) on each other, you have a state machine.

Persisting state machines

A document lives longer than a process. Store the state name in a column, and on load map it back to the state object: STATES[row.state]. Two editors clicking "approve" at the same moment are two processes loading the same row, so the in-memory object cannot protect you. Make each transition a conditional write, UPDATE docs SET state = 'approved', version = version + 1 WHERE id = $1 AND state = 'review' AND version = $2, and treat zero rows updated as "someone else moved it first". The state pattern decides what is legal; the database decides who wins.

Reviewing agent-written code

Asked to add a state, agents tend to add one more elif status == ... to each method they happen to open and miss the others. Asked to "use the State pattern", they sometimes put data in the states, or create a new state object on every transition. In review, check that every operation is defined for every state (the base class should refuse by default), that data stays on the context, that the state name written to storage round-trips to the same object, and that tests cover the illegal transitions as well as the happy path. A state diagram in the pull request makes missing edges obvious.

The same shape elsewhere

State is the multi-step consistency shape in miniature: a long-lived entity moves through steps, and each step allows a different set of operations. Orders (placed, paid, shipped, delivered, refunded), payment intents, Kubernetes pods (Pending, Running, Succeeded, Failed), TCP connections (LISTEN, SYN-RECEIVED, ESTABLISHED, FIN-WAIT-1 and so on), and pull requests all have the same structure and the same bugs: transitions that skip a guard, two actors racing on one record, and states added without updating every handler.

Down the stack

Layer 1The contract

A state machine promises: every operation is either valid in the current state, in which case it performs its effect and moves to exactly one next state, or invalid, in which case it fails with an error that names the operation and the state and changes nothing. Guards (who may approve, whether the text is empty) are checked before any mutation. The history of states is append-only. Across processes, the contract adds: a transition based on a stale read must fail, not overwrite a newer state. Tests should cover every (state, operation) pair, which for 5 states and 6 operations is 30 cases, most of them refusals.

Layer 2The mechanism

The Linux kernel uses the enum form for TCP. sk->sk_state holds values such as TCP_ESTABLISHED, TCP_SYN_RECV and TCP_FIN_WAIT1, and tcp_rcv_state_process switches on it to decide what an incoming segment means. The states and transitions follow the diagram in RFC 9293. The enum form suits the kernel: the set of states is fixed by a standard, and the handling for each segment type is easier to read side by side.

Go's text/template lexer, described in Rob Pike's "Lexical Scanning in Go" talk, uses state functions: type stateFn func(*lexer) stateFn. Each state is a function that does its work and returns the next state, and the run loop is for state := lexStart; state != nil; { state = state(l) }. It is the State pattern with functions instead of classes, and it has no switch at all.

Statechart libraries such as XState add what plain state machines lack: hierarchical states (a "review" superstate containing "awaiting first approval" and "awaiting second"), guards on transitions, entry and exit actions, and parallel regions. Workflow engines like AWS Step Functions and Temporal persist the current state and its history durably, so a machine can survive restarts and run for months.

Layer 3The machine

An enum state is one byte or one machine word. A switch on a dense enum compiles to a bounds check and a jump table: two or three instructions and one indirect jump. A state object is one pointer (8 bytes) on the context, and each operation is an indirect call through the object's vtable (C++, Java), itab (Go) or prototype chain lookup (JavaScript). Predicted, that costs 1 to 2 ns; mispredicted, 15 to 20 cycles. At the rates a workflow sees (a few transitions per document per day) neither number matters. It matters in parsers and protocol stacks that run a transition per byte or per packet, which is why lexers and TCP use tight loops over enums or functions.

Allocation is the other cost. If each transition creates a new state object, a busy service allocates on every event and the garbage collector pays for it. Stateless states shared as singletons remove that cost entirely. In V8, a call site like this.state.approve(...) sees a different hidden class per state. With five states it becomes megamorphic (more than four shapes), and V8 falls back to a slower generic property lookup. Using one class with a function table, or keeping hot paths to a few states, keeps the call site fast.

Concurrency belongs at the boundary. Within one process, guard the context with a mutex or confine it to one goroutine or event loop, so a transition's guard check and its write cannot interleave with another. Across processes, the database row is the lock: the conditional UPDATE above is a compare-and-set, and on Postgres it takes a row lock only for the duration of the statement.

Why it's built this way

  1. Long-lived entities move through modes, and each mode allows different operations, so the rules form a graph.
  2. Encoding that graph as checks scattered across methods hides it, and new states miss checks, so each mode becomes one class that holds its rules.
  3. Refusal is the common case, so a base class refuses by default and states override only what they allow.
  4. States are behaviour and data lives on the context, so states can be shared singletons and transitions are pointer swaps.
  5. The process is not the source of truth, so the state name is persisted and transitions become conditional writes.

Diagram

stateDiagram-v2
  [*] --> draft
  draft --> draft: edit (author)
  draft --> review: submit (author, text not empty)
  review --> review: approve (below required)
  review --> approved: approve (reaches required)
  review --> draft: reject (clears approvals)
  approved --> draft: reject (clears approvals)
  approved --> published: publish (author)
  published --> archived: archive
  archived --> [*]

Worked example

A newsroom publishes about 600 articles a day; each passes through 9 statuses on average, so the system handles roughly 5,400 transitions a day plus about 40,000 edits. The original code had a status column and 37 places that read it, across the web app, a mobile API and two background jobs. An audit found 4 of those places allowed edits in a state where edits should have been blocked. In one incident a correction was applied to an article after legal review, and the legally reviewed version was never the one readers saw.

The team moved the rules into one module with a state class per status and a base class that refuses everything. Every entry point now loads the article, calls the context method, and saves with a conditional update on (state, version). The test suite enumerates all 12 statuses against all 9 operations, 108 cases, generated from a table. In the first month, conditional updates rejected about 30 transitions a day, almost all of them double clicks and two editors approving at once; before, those had silently overwritten each other. Adding the "scheduled" status later touched one new class, two transitions and the table. The per-operation overhead of the state lookup is under a microsecond against a 4 ms database write, so the refactor cost nothing measurable at runtime.

Trade-offs

OptionStrengthsWeaknessesChoose when
Status field and if checksLeast code at firstRules scattered; new states miss checksTwo or three states with trivial rules
Enum plus transition tableWhole graph in one place; easy to validate and drawBehaviour per state ends up in switches elsewhereMany states, simple transitions, generated diagrams
State pattern (class per state)Behaviour and rules for a mode in one place; refusals by defaultMore types; states know their successorsEach state has real behaviour and guards
State functionsNo classes, no switch; very fastHarder to persist or inspectLexers, parsers, protocol loops
Statechart library or workflow engineHierarchy, guards, durability, visual toolsDependency and learning curveComplex or long-running workflows

Pitfalls in production

Leadership lens

Check yourself

  1. Why should state objects avoid holding data such as the approval list?

    Show answer

    Data in state objects must be copied on every transition, and a missed copy loses it. With data on the context, states are stateless, can be shared, and transitions are simple pointer swaps.

  2. Two editors approve the same document at the same moment from different servers. What prevents a lost update?

    Show answer

    A conditional database write on the expected state and version. One update succeeds; the other affects zero rows and must reload and retry or report the conflict.

  3. When is an enum with a transition table better than one class per state?

    Show answer

    When transitions are simple and you value seeing the whole graph in one place, such as a fixed protocol, or when you want to generate diagrams and exhaustive tests from the table.

  4. How do you tell whether code you are reading is Strategy or State?

    Show answer

    Look at who swaps the object. If the objects replace each other in response to events, it is State; if the choice comes from outside and stays fixed, it is Strategy.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →