Skip to content

Low Level DesignDesign patterns

State pattern

The State design pattern through three real examples, from a publishing workflow to an order lifecycle to order events racing across servers, with diagrams, live demos and code in five languages.

Mid-level22 min read

The State pattern gives an object one small object per situation it can be in, and hands every action to the one that is current. An article in review can be approved but not edited. A shipped order can be delivered but not cancelled. Instead of every method asking “which status am I in?”, each status is a class that knows what it allows and which status comes next.

It fits anything with a lifecycle: documents, orders, payments, tickets, network connections. TCP’s connection states are the textbook example. And every order system has one, whether or not anyone drew it. This page builds in three steps. First, a three-state workflow. Then an order with side effects and guards. Then state that lives in a database, and changes under events that race.

The idea in one picture
Document text, review note, one state approve() → state.approve(this) no if, no switch on status Draft InReview Published submit approve reject current state InReview handles approve and reject. Draft has no approve(), so it's refused. each state does its part, then points the document at the next state: d.transitionTo(new Published())
Scroll or pinch to zoom, drag to pan
The document points at one state object and forwards every action to it. The state does what is legal in it, then points the document at the next state.

Every State pattern has three parts:

  • The context is the object with a lifecycle, for example Document or Order. It holds the data and a reference to its current state, and forwards each action to that state.
  • The state interface has one method per action (or one method that takes the event). Its default answer is “not in this state”.
  • Concrete states each handle the actions that are legal in them, and move the context to the next state.
The general shape

current

next

next

back

Context

-state: State

+request()

~transitionTo(State)

«interface»

State

+handle(context)

StateA

StateB

StateC

current

next

next

back

Context

-state: State

+request()

~transitionTo(State)

«interface»

State

+handle(context)

StateA

StateB

StateC

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation

If this looks like Strategy, it is the same shape. The difference is the dashed arrows between the states. A strategy is chosen from outside, and stays put. A state knows its successors, and replaces itself as things happen.

Easy

A publishing workflow

  • A class per state
  • Illegal actions have no code path
  • States choose the next state

A company’s help center publishes articles. A writer drafts an article and submits it for review. An editor approves it, which publishes it, or rejects it with a note, which sends it back to the writer as a draft. Only drafts can be edited, nothing goes live without review, and published articles are read-only.

The first version keeps a status enum and checks it in every method:

Document.javadocument.pyDocument.csdocument.godocument.ts
// Before: the status is an enum, and every method asks "which status am I in?"
enum Status { DRAFT, IN_REVIEW, PUBLISHED }
class Document {
Status status = Status.DRAFT;
private String text;
Document(String text) { this.text = text; }
// flaw 1: four methods times three statuses. The rules for one status (say, IN_REVIEW)
// are spread across all four methods, so nobody can read them in one place.
void edit(String newText) {
switch (status) {
case DRAFT -> text = newText;
case IN_REVIEW -> throw new IllegalStateException("locked for review");
case PUBLISHED -> throw new IllegalStateException("published documents are read-only");
}
}
void submit() {
if (status != Status.DRAFT) throw new IllegalStateException("only a draft can be submitted");
status = Status.IN_REVIEW;
}
void approve() {
// flaw 2: written when there were only DRAFT and PUBLISHED. IN_REVIEW was added
// later, nothing forced anyone to revisit this check, and a draft now skips review.
if (status == Status.PUBLISHED) throw new IllegalStateException("already published");
status = Status.PUBLISHED;
}
void reject(String note) {
if (status != Status.IN_REVIEW) throw new IllegalStateException("nothing to reject");
status = Status.DRAFT;
}
}
class Main {
public static void main(String[] args) {
Document doc = new Document("Q3 pricing update");
doc.edit("Q3 pricing update, v2");
System.out.println("after edit: " + doc.status); // after edit: DRAFT
// Should be refused: nobody has reviewed it. Nothing stops it.
doc.approve();
System.out.println("after approve: " + doc.status); // after approve: PUBLISHED
}
}

It started with two statuses, and approve() was written then: “refuse if already published”. When review was added, submit() and reject() were written for it. But nothing pointed anyone back at approve(). So a draft can still be approved straight to the public site. The rules for “in review” are spread across four methods, and every new status means reading all of them again. A switch expression over the enum would catch a missing case, but only in methods that use one. The if checks here are invisible to the compiler.

Make each status a class. The document forwards every action to its current state. A state implements only what is legal in it, and the interface’s defaults refuse everything else:

Easy: the lifecycle

edit

submit

approve

reject

Draft

InReview

Published

edit

submit

approve

reject

Draft

InReview

Published

Scroll or pinch to zoom, drag to pan
  • Color key
  • State
  • Ends well
Easy: one class per state

current

submit

approve

reject

Document

-state: DocState

-text

-reviewNote

+edit(text)

+submit()

+approve()

+reject(note)

~transitionTo(DocState)

«interface»

DocState

+edit(doc, text)

+submit(doc)

+approve(doc)

+reject(doc, note)

Draft

+edit(doc, text)

+submit(doc)

InReview

+approve(doc)

+reject(doc, note)

Published

current

submit

approve

reject

Document

-state: DocState

-text

-reviewNote

+edit(text)

+submit()

+approve()

+reject(note)

~transitionTo(DocState)

«interface»

DocState

+edit(doc, text)

+submit(doc)

+approve(doc)

+reject(doc, note)

Draft

+edit(doc, text)

+submit(doc)

InReview

+approve(doc)

+reject(doc, note)

Published

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation

Click through the workflow. With State classes, the current state’s class says up front what it handles, so illegal actions are disabled with the reason. Switch to the enum version and approve a draft:

  1. Draftedit(), submit()
  2. InReviewapprove(), reject()
  3. Publishednothing: all refused

Document Q3 pricing update, v1 is in Draft

Document.javadocument.pyProgram.csdocument.godocument.ts
// One method per action. The defaults refuse, so each state overrides only the actions
// that are legal in it. An action a state doesn't mention has no code path to run.
interface DocState {
String name();
default void edit(Document d, String text) { throw refuse("edit"); }
default void submit(Document d) { throw refuse("submit"); }
default void approve(Document d) { throw refuse("approve"); }
default void reject(Document d, String note) { throw refuse("reject"); }
private IllegalStateException refuse(String action) {
return new IllegalStateException(name() + " cannot " + action);
}
}
// Every rule about drafts is in this class, and nowhere else.
class Draft implements DocState {
public String name() { return "Draft"; }
public void edit(Document d, String text) { d.setText(text); }
// The state picks the next state. The document never decides where to go.
public void submit(Document d) { d.transitionTo(new InReview()); }
}
class InReview implements DocState {
public String name() { return "InReview"; }
// A state can refuse with a better reason than the default.
public void edit(Document d, String text) {
throw new IllegalStateException("InReview cannot edit: locked for review, reject it to make changes");
}
public void approve(Document d) { d.transitionTo(new Published()); }
public void reject(Document d, String note) {
d.setReviewNote(note);
d.transitionTo(new Draft());
}
}
// Published handles nothing, so every action is refused by the defaults.
class Published implements DocState {
public String name() { return "Published"; }
}
// The context. It holds the data and the current state, and forwards every action.
// There is no switch on the state anywhere in this class.
class Document {
// States hold no data, so a new one is cheap. You could also share one of each.
private DocState state = new Draft();
private String text;
private String reviewNote = "";
Document(String text) { this.text = text; }
void edit(String text) { state.edit(this, text); }
void submit() { state.submit(this); }
void approve() { state.approve(this); }
void reject(String note) { state.reject(this, note); }
// Package-private: only states move the document. One method for every transition
// is also the one place to log, audit or notify (the Hard level builds on this).
void transitionTo(DocState next) {
System.out.println(state.name() + " -> " + next.name());
state = next;
}
void setText(String text) { this.text = text; }
void setReviewNote(String note) { this.reviewNote = note; }
String reviewNote() { return reviewNote; }
}
class Main {
static void attempt(Runnable action) {
try { action.run(); } catch (IllegalStateException e) { System.out.println("refused: " + e.getMessage()); }
}
public static void main(String[] args) {
Document doc = new Document("Q3 pricing update");
doc.edit("Q3 pricing update, v2");
attempt(doc::approve); // refused: Draft cannot approve
doc.submit(); // Draft -> InReview
attempt(() -> doc.edit("sneaky change")); // refused: InReview cannot edit: locked for review, reject it to make changes
doc.reject("cite the source for the new prices"); // InReview -> Draft
System.out.println("note: " + doc.reviewNote()); // note: cite the source for the new prices
doc.edit("Q3 pricing update, v3");
doc.submit(); // Draft -> InReview
doc.approve(); // InReview -> Published
attempt(() -> doc.reject("too late")); // refused: Published cannot reject
}
}
  • Each state’s rules are in one place. Everything about review is in InReview. It can be approved or rejected, and editing is refused with a reason. Reading one class tells you everything that can happen to an article in review.
  • Illegal actions have no code path. Draft doesn’t implement approve, so the default refuses it with Draft cannot approve. The bug in the enum version can’t be written here: approving a draft would mean adding an approve method to Draft on purpose.
  • States choose the next state. InReview.approve decides that approval leads to Published. The document doesn’t know the graph. It only knows its current state.
  • One method changes state. Every transition goes through transitionTo. That’s where logging, auditing and notifications go, once, and the Hard level builds on it.
  • States hold no data. The text and the review note live in Document. A state is pure behavior, so one instance of each could be shared by every document, and switching state is assigning a reference. The vending machine makes the same choice for the same reason.
  • New states aren’t free. A “Scheduled” state is a new class, plus an edit to whichever state leads into it. That’s still far less than reopening every method of the enum version.
Interviewer

Editors want to schedule articles: approve now, go live Monday at 9. What changes?

What they are testing

Whether the answer is a new Scheduled state (edit refused, publishNow and unschedule allowed). Plus a change to InReview.approve, to go there when a date is set, with Document untouched. The red flag is adding if (status == SCHEDULED) to each method. So is forgetting to decide what edit means while scheduled.

Say this out loud

Every action means something different, depending on the article’s status. So I’ll make each status a class behind a DocState interface. The document forwards actions to its current state. The state does what’s legal, and picks the next state. Anything a state doesn’t handle is refused by default.

Medium

An order lifecycle

  • Entry and exit actions
  • Guards
  • Classes vs. a transition table

An online store’s orders start in PendingPayment, with a 30-minute timer that cancels them if the customer never pays. A paid order ships once the fraud check has cleared. The customer can cancel until the parcel ships, and a paid order that is cancelled must be refunded. After delivery, a return refunds the payment. Each step has side effects: start and stop the payment timer, email the tracking link, release reserved stock, refund, restock.

Medium: the order lifecycle

pay

cancel

ship [fraud check passed]

cancel / refund payment

deliver

return / refund payment

PendingPayment

Paid

Cancelled

Shipped

Delivered

Refunded

pay

cancel

ship [fraud check passed]

cancel / refund payment

deliver

return / refund payment

PendingPayment

Paid

Cancelled

Shipped

Delivered

Refunded

Scroll or pinch to zoom, drag to pan
  • Color key
  • State
  • Ends well

The side effects come in three kinds, and putting each in the right place is most of this level:

  • Entry and exit actions belong to a state. Entering PendingPayment starts the timer. Leaving it stops the timer, whichever way the order leaves.
  • Transition actions belong to one move. Paid to Cancelled refunds the payment. PendingPayment to Cancelled must not, even though both end in the same state.
  • Guards are conditions on data that a move needs. ship from Paid exists, but only once the fraud check has passed. cancel from Shipped doesn’t exist at all.

There are two common ways to write this, and both are in production everywhere. The demo runs the same order through both at once:

Class per state

class PendingPayment

    Transition table

    fromeventguard / actionto
    PendingPaymentpayPaid
    PendingPaymentcancelCancelled
    Paidship[fraud check passed]Shipped
    Paidcancel/ refund paymentCancelled
    ShippeddeliverDelivered
    Deliveredreturn/ refund paymentRefunded

    Medium: states with entry and exit actions

    current

    Order

    -state: OrderState

    -fraudCheckPassed

    +pay()

    +ship()

    +deliver()

    +cancel()

    +requestReturn()

    ~transitionTo(next, action)

    «interface»

    OrderState

    +onEnter(order)

    +onExit(order)

    +pay(order)

    +ship(order)

    +deliver(order)

    +cancel(order)

    +requestReturn(order)

    PendingPayment

    +onEnter(order)

    +onExit(order)

    +pay(order)

    +cancel(order)

    Paid

    +ship(order)

    +cancel(order)

    Shipped

    +onEnter(order)

    +deliver(order)

    Delivered

    +requestReturn(order)

    Cancelled

    +onEnter(order)

    Refunded

    +onEnter(order)

    current

    Order

    -state: OrderState

    -fraudCheckPassed

    +pay()

    +ship()

    +deliver()

    +cancel()

    +requestReturn()

    ~transitionTo(next, action)

    «interface»

    OrderState

    +onEnter(order)

    +onExit(order)

    +pay(order)

    +ship(order)

    +deliver(order)

    +cancel(order)

    +requestReturn(order)

    PendingPayment

    +onEnter(order)

    +onExit(order)

    +pay(order)

    +cancel(order)

    Paid

    +ship(order)

    +cancel(order)

    Shipped

    +onEnter(order)

    +deliver(order)

    Delivered

    +requestReturn(order)

    Cancelled

    +onEnter(order)

    Refunded

    +onEnter(order)

    Scroll or pinch to zoom, drag to pan
    • Color key
    • Facade
    • Entity
    • Value object
    • Enum
    • Interface
    • Implementation
    Main.javaorder.pyProgram.csorder.goorder.ts
    // Entry and exit actions default to nothing; events default to refusing.
    // A state overrides only what it does.
    interface OrderState {
    String name();
    default void onEnter(Order o) {}
    default void onExit(Order o) {}
    default void pay(Order o) { throw refuse("pay"); }
    default void ship(Order o) { throw refuse("ship"); }
    default void deliver(Order o) { throw refuse("deliver"); }
    default void cancel(Order o) { throw refuse("cancel"); }
    default void requestReturn(Order o) { throw refuse("return"); }
    private IllegalStateException refuse(String event) {
    return new IllegalStateException(name() + " cannot " + event);
    }
    }
    class PendingPayment implements OrderState {
    public String name() { return "PendingPayment"; }
    // Started on entry, stopped on exit. The timer stops whichever way the order leaves
    // (paid or cancelled), and no transition has to remember to do it.
    public void onEnter(Order o) { o.log("enter PendingPayment: start payment timer"); }
    public void onExit(Order o) { o.log("exit PendingPayment: stop payment timer"); }
    public void pay(Order o) { o.transitionTo(new Paid(), null); }
    public void cancel(Order o) { o.transitionTo(new Cancelled(), null); }
    }
    class Paid implements OrderState {
    public String name() { return "Paid"; }
    public void ship(Order o) {
    // A guard: the move exists from this state, but only while a condition on the data
    // holds. Different from Shipped.cancel, which doesn't exist at all.
    if (!o.fraudCheckPassed()) throw new IllegalStateException("fraud check not passed yet");
    o.transitionTo(new Shipped(), null);
    }
    // A transition action: it belongs to this move (Paid -> Cancelled), not to either state.
    // Cancelling an unpaid order goes to the same state and must not refund.
    public void cancel(Order o) { o.transitionTo(new Cancelled(), "refund payment"); }
    }
    class Shipped implements OrderState {
    public String name() { return "Shipped"; }
    public void onEnter(Order o) { o.log("enter Shipped: email tracking link"); }
    public void deliver(Order o) { o.transitionTo(new Delivered(), null); }
    // No cancel(): once the parcel has left, the default refuses it.
    }
    class Delivered implements OrderState {
    public String name() { return "Delivered"; }
    public void requestReturn(Order o) { o.transitionTo(new Refunded(), "refund payment"); }
    }
    class Cancelled implements OrderState {
    public String name() { return "Cancelled"; }
    public void onEnter(Order o) { o.log("enter Cancelled: release reserved stock"); }
    }
    class Refunded implements OrderState {
    public String name() { return "Refunded"; }
    public void onEnter(Order o) { o.log("enter Refunded: restock returned item"); }
    }
    class Order {
    final String id;
    private OrderState state;
    private boolean fraudCheckPassed;
    Order(String id) {
    this.id = id;
    System.out.println(id + " created");
    state = new PendingPayment();
    state.onEnter(this); // the first state's entry action runs too
    }
    void pay() { state.pay(this); }
    void ship() { state.ship(this); }
    void deliver() { state.deliver(this); }
    void cancel() { state.cancel(this); }
    void requestReturn() { state.requestReturn(this); }
    // Data, not an event: it changes no state by itself. Guards read it.
    void passFraudCheck() { fraudCheckPassed = true; }
    boolean fraudCheckPassed() { return fraudCheckPassed; }
    // Exit, then the transition's action, then entry: the order UML state machines use.
    // In real code the action and the entry and exit actions call services; here they print.
    void transitionTo(OrderState next, String action) {
    state.onExit(this);
    log(state.name() + " -> " + next.name() + (action == null ? "" : ": " + action));
    state = next;
    next.onEnter(this);
    }
    void log(String line) { System.out.println(" " + line); }
    }
    class Main {
    static void step(Order o, String event, Runnable r) {
    System.out.println(o.id + " " + event);
    try { r.run(); } catch (IllegalStateException e) { o.log("refused: " + e.getMessage()); }
    }
    public static void main(String[] args) {
    Order a = new Order("A1");
    // A1 created
    // enter PendingPayment: start payment timer
    step(a, "pay", a::pay);
    // A1 pay
    // exit PendingPayment: stop payment timer
    // PendingPayment -> Paid
    step(a, "ship", a::ship);
    // A1 ship
    // refused: fraud check not passed yet
    a.passFraudCheck();
    step(a, "ship", a::ship);
    // A1 ship
    // Paid -> Shipped
    // enter Shipped: email tracking link
    step(a, "cancel", a::cancel);
    // A1 cancel
    // refused: Shipped cannot cancel
    step(a, "deliver", a::deliver);
    // A1 deliver
    // Shipped -> Delivered
    step(a, "return", a::requestReturn);
    // A1 return
    // Delivered -> Refunded: refund payment
    // enter Refunded: restock returned item
    Order b = new Order("B2");
    // B2 created
    // enter PendingPayment: start payment timer
    step(b, "pay", b::pay);
    // B2 pay
    // exit PendingPayment: stop payment timer
    // PendingPayment -> Paid
    step(b, "cancel", b::cancel);
    // B2 cancel
    // Paid -> Cancelled: refund payment
    // enter Cancelled: release reserved stock
    }
    }

    The same machine as data: one row per legal move, with its guard and action, and a map of entry and exit actions per state. The Order has one generic fire(event) that looks up the row.

    Medium: the machine as rows

    looks up

    6

    Order

    -state: String

    -fraudCheckPassed

    +fire(event)

    Transition

    +from

    +event

    +to

    +guard

    +action

    OrderRules

    +TABLE: List<Transition>

    +ON_ENTER: Map

    +ON_EXIT: Map

    looks up

    6

    Order

    -state: String

    -fraudCheckPassed

    +fire(event)

    Transition

    +from

    +event

    +to

    +guard

    +action

    OrderRules

    +TABLE: List<Transition>

    +ON_ENTER: Map

    +ON_EXIT: Map

    Scroll or pinch to zoom, drag to pan
    • Color key
    • Facade
    • Entity
    • Value object
    • Enum
    • Interface
    • Implementation
    Main.javaorder.pyProgram.csorder.goorder.ts
    import java.util.HashSet;
    import java.util.List;
    import java.util.Map;
    import java.util.Set;
    import java.util.function.Predicate;
    // One row per legal move. Anything not in the table is refused, so the table is the
    // whole state machine: you can print it, draw it, test it, or load it from config.
    record Transition(String from, String event, String to,
    Predicate<Order> guard, String guardFails, String action) {
    static Transition of(String from, String event, String to) {
    return new Transition(from, event, to, o -> true, null, null);
    }
    Transition when(Predicate<Order> g, String whyNot) { return new Transition(from, event, to, g, whyNot, action); }
    Transition doing(String a) { return new Transition(from, event, to, guard, guardFails, a); }
    }
    final class OrderRules {
    static final List<Transition> TABLE = List.of(
    Transition.of("PendingPayment", "pay", "Paid"),
    Transition.of("PendingPayment", "cancel", "Cancelled"),
    Transition.of("Paid", "ship", "Shipped")
    .when(Order::fraudCheckPassed, "fraud check not passed yet"),
    Transition.of("Paid", "cancel", "Cancelled").doing("refund payment"),
    Transition.of("Shipped", "deliver", "Delivered"),
    Transition.of("Delivered", "return", "Refunded").doing("refund payment"));
    // Entry and exit actions are per state, so they live beside the table, not in a row.
    static final Map<String, String> ON_ENTER = Map.of(
    "PendingPayment", "start payment timer",
    "Shipped", "email tracking link",
    "Cancelled", "release reserved stock",
    "Refunded", "restock returned item");
    static final Map<String, String> ON_EXIT = Map.of("PendingPayment", "stop payment timer");
    // Data can be checked when it loads. Two rows for the same (state, event) would make
    // the machine depend on row order, so refuse to start.
    static {
    Set<String> seen = new HashSet<>();
    for (Transition t : TABLE)
    if (!seen.add(t.from() + "/" + t.event()))
    throw new IllegalStateException("duplicate row: " + t.from() + " on " + t.event());
    }
    }
    class Order {
    final String id;
    private String state = "PendingPayment";
    private boolean fraudCheckPassed;
    Order(String id) {
    this.id = id;
    System.out.println(id + " created");
    enter(state); // the first state's entry action runs too
    }
    // One generic method replaces pay(), ship(), cancel()... The cost: a typo in an event
    // name is a run-time refusal, not a compile error.
    void fire(String event) {
    Transition t = OrderRules.TABLE.stream()
    .filter(r -> r.from().equals(state) && r.event().equals(event))
    .findFirst()
    .orElseThrow(() -> new IllegalStateException(state + " cannot " + event));
    if (!t.guard().test(this)) throw new IllegalStateException(t.guardFails());
    // Same order as the class version: exit, the row's action, entry.
    String exit = OrderRules.ON_EXIT.get(state);
    if (exit != null) log("exit " + state + ": " + exit);
    log(state + " -> " + t.to() + (t.action() == null ? "" : ": " + t.action()));
    state = t.to();
    enter(state);
    }
    private void enter(String s) {
    String entry = OrderRules.ON_ENTER.get(s);
    if (entry != null) log("enter " + s + ": " + entry);
    }
    void passFraudCheck() { fraudCheckPassed = true; }
    boolean fraudCheckPassed() { return fraudCheckPassed; }
    void log(String line) { System.out.println(" " + line); }
    }
    class Main {
    static void step(Order o, String event) {
    System.out.println(o.id + " " + event);
    try { o.fire(event); } catch (IllegalStateException e) { o.log("refused: " + e.getMessage()); }
    }
    public static void main(String[] args) {
    // The same script as the class version, and the same output, line for line.
    Order a = new Order("A1");
    // A1 created
    // enter PendingPayment: start payment timer
    step(a, "pay");
    // A1 pay
    // exit PendingPayment: stop payment timer
    // PendingPayment -> Paid
    step(a, "ship");
    // A1 ship
    // refused: fraud check not passed yet
    a.passFraudCheck();
    step(a, "ship");
    // A1 ship
    // Paid -> Shipped
    // enter Shipped: email tracking link
    step(a, "cancel");
    // A1 cancel
    // refused: Shipped cannot cancel
    step(a, "deliver");
    // A1 deliver
    // Shipped -> Delivered
    step(a, "return");
    // A1 return
    // Delivered -> Refunded: refund payment
    // enter Refunded: restock returned item
    Order b = new Order("B2");
    // B2 created
    // enter PendingPayment: start payment timer
    step(b, "pay");
    // B2 pay
    // exit PendingPayment: stop payment timer
    // PendingPayment -> Paid
    step(b, "cancel");
    // B2 cancel
    // Paid -> Cancelled: refund payment
    // enter Cancelled: release reserved stock
    }
    }

    Both programs run the same script and print the same output, line for line.

    • Exit actions catch every way out. The timer is stopped by PendingPayment’s exit action. So neither pay nor cancel has to remember it. And a third way out, added next year, can’t forget it either.
    • The order of effects is fixed. Exit actions run first, then the transition’s action, then entry actions. That’s the order UML state machines use, and it means a state’s exit always sees the order as it was.
    • A guard is not a missing transition. Shipped cannot cancel means “never from here”. fraud check not passed yet means “not yet”. The caller handles them differently. The first is a final answer for the customer. The second is worth retrying.
    • The fraud check is data, not an event. Clearing it changes no state by itself. The guard reads it when ship arrives. If clearing it should ship the order automatically, make it an event instead.
    • The table can be checked as data. It is validated when it loads: no two rows for the same state and event. It can be printed, drawn or diffed in review. The classes can’t be inspected that way without running them.
    • Names in the table are strings. A typo in an event name is a refusal at run time. The TypeScript version uses string unions, so the compiler checks the table. In the others, the load-time check is the safety net.
    Class per state Transition table
    Where a state’s behavior lives In its class, next to its transitions Spread across rows and the entry and exit maps
    Seeing the whole machine Read every class, or keep a diagram up to date Read one list; draw it from the data
    States that do real work per event Natural: each method is ordinary code Awkward: actions become names mapped to code
    Changing the rules without a release No Possible: rows can come from config
    Compiler help Methods per event are checked Only if names are typed (enums, unions)

    For this order, states differ mostly in which moves are legal, and the table is shorter. The parking lot makes that call for its ticket. The vending machine goes the other way. Every coin, button and sensor report means something different in every state. So a class per state keeps each answer in one place. Many real systems mix them. A table says which moves are legal, and per-state code says what each state does.

    Interviewer

    Customers can cancel until the parcel ships. Where does that rule live, and how does the app know whether to show the cancel button?

    What they are testing

    Whether the rule lives once, in the state machine (no cancel in Shipped, or no row for it). And whether the app asks the machine which events are allowed now, from the current state’s methods or the table’s rows. The red flag is copying “status is Paid or PendingPayment” into the frontend. The two copies drift, and the button shows for orders that will refuse.

    Hard

    Order events from many servers

    • State stored as a name and a version
    • Compare-and-set on every transition
    • Idempotent event handling
    • Audit log and outbox

    The order service now runs on several servers. Each order is a row in the database, with its state’s name. Events reach it from everywhere. The customer clicks. The payment provider sends webhooks. And the warehouse asks “may I ship A1?” right before handing the parcel to the carrier. Webhooks are delivered at least once. So after a timeout, the same event arrives again.

    The state classes still decide what is legal. What’s new is that two servers can load the same order, each decide on what they read, and each write back.

    The order is Paid. The warehouse asks to ship it at the same second the customer cancels. Both handlers read the row before either writes:

    ordersA1state Paidversion 2

    Warehouse evt-2 ship

      Customer evt-3 cancel

        Effects sent

          Audit log

            With last write wins, both decisions were legal on what each handler read, so both apply. The parcel goes to the carrier and the refund goes to the customer, and the row only remembers whichever write landed last:

            Main.javaorder_events.pyProgram.csorder_events.goorder-events.ts
            import java.util.ArrayList;
            import java.util.HashMap;
            import java.util.List;
            import java.util.Map;
            record Event(String id, String type) {}
            // What a state decides for one event: move to `next` and send `effect`, or refuse.
            record Decision(String next, String effect, String refusal) {
            static Decision go(String next, String effect) { return new Decision(next, effect, null); }
            static Decision refuse(String state, String event) {
            return new Decision(null, null, "cannot " + event + ", order is " + state);
            }
            }
            // Events now arrive as data from a queue, so a state takes the event's name.
            interface OrderState {
            String name();
            Decision on(String event);
            }
            class PendingPayment implements OrderState {
            public String name() { return "PendingPayment"; }
            public Decision on(String event) {
            return switch (event) {
            case "pay" -> Decision.go("Paid", "email receipt");
            case "cancel" -> Decision.go("Cancelled", "release stock");
            default -> Decision.refuse(name(), event);
            };
            }
            }
            class Paid implements OrderState {
            public String name() { return "Paid"; }
            public Decision on(String event) {
            return switch (event) {
            case "ship" -> Decision.go("Shipped", "email tracking link");
            case "cancel" -> Decision.go("Cancelled", "refund payment");
            default -> Decision.refuse(name(), event);
            };
            }
            }
            class Shipped implements OrderState {
            public String name() { return "Shipped"; }
            public Decision on(String event) {
            return event.equals("deliver") ? Decision.go("Delivered", null) : Decision.refuse(name(), event);
            }
            }
            class Delivered implements OrderState {
            public String name() { return "Delivered"; }
            public Decision on(String event) { return Decision.refuse(name(), event); }
            }
            class Cancelled implements OrderState {
            public String name() { return "Cancelled"; }
            public Decision on(String event) { return Decision.refuse(name(), event); }
            }
            final class States {
            private static final Map<String, OrderState> BY_NAME = new HashMap<>();
            static {
            for (OrderState s : List.of(new PendingPayment(), new Paid(), new Shipped(), new Delivered(), new Cancelled()))
            BY_NAME.put(s.name(), s);
            }
            static OrderState fromName(String name) { return BY_NAME.get(name); }
            }
            // Stands in for the database: one row per order, holding only the state's name.
            class OrderStore {
            private final Map<String, String> rows = new HashMap<>();
            final List<String> sent = new ArrayList<>();
            synchronized void insert(String id, String state) { rows.put(id, state); }
            synchronized String load(String id) { return rows.get(id); }
            // flaw 1: last write wins. Nothing checks that the row still holds what the handler read.
            synchronized void save(String id, String state) { rows.put(id, state); }
            }
            class OrderEvents {
            private final OrderStore store;
            OrderEvents(OrderStore store) { this.store = store; }
            void handle(String orderId, Event e) { apply(orderId, e, store.load(orderId)); }
            void apply(String orderId, Event e, String state) {
            Decision d = States.fromName(state).on(e.type());
            if (d.refusal() != null) { report(e, "refused: " + d.refusal()); return; }
            store.save(orderId, d.next());
            // flaw 2: the effect goes out at once, even if another handler's write lands on top.
            if (d.effect() != null) store.sent.add(d.effect());
            report(e, state + " -> " + d.next());
            }
            private static void report(Event e, String what) { System.out.println(e.id() + " " + e.type() + ": " + what); }
            }
            class Main {
            public static void main(String[] args) {
            OrderStore store = new OrderStore();
            OrderEvents events = new OrderEvents(store);
            store.insert("A1", "PendingPayment");
            events.handle("A1", new Event("evt-1", "pay")); // evt-1 pay: PendingPayment -> Paid
            // The warehouse asks to ship while the customer cancels. Both handlers read the
            // row before either writes, as two servers would.
            String warehouseRead = store.load("A1");
            String customerRead = store.load("A1");
            events.apply("A1", new Event("evt-2", "ship"), warehouseRead); // evt-2 ship: Paid -> Shipped
            events.apply("A1", new Event("evt-3", "cancel"), customerRead); // evt-3 cancel: Paid -> Cancelled
            // flaw 3: the warehouse's webhook timed out and is sent again. Nothing remembers
            // evt-2, so the warehouse gets an error for a parcel it has already handed over.
            events.handle("A1", new Event("evt-2", "ship")); // evt-2 ship: refused: cannot ship, order is Cancelled
            System.out.println("state: " + store.load("A1")); // state: Cancelled
            System.out.println("sent: " + String.join(", ", store.sent)); // sent: email receipt, email tracking link, refund payment
            // The parcel is with the carrier and the money is back with the customer.
            }
            }

            The state classes did nothing wrong. Each decision was correct for the state it was given. But the state was stale by the time it was saved. The fix is in how a decision becomes a change.

            Hard: pure states, a versioned row, one transaction per transition

            rebuilds state

            5

            returns

            OrderEvents

            +handle(orderId, Event)

            +apply(orderId, Event, Row)

            OrderStore

            +load(id) : Row

            +seen(eventId) : bool

            +commit(id, read, next, Event, effect) : bool

            States

            +fromName(name) : OrderState

            «interface»

            OrderState

            +name()

            +on(event) : Decision

            Row

            +state

            +version

            Event

            +id

            +type

            Decision

            +next

            +effect

            +refusal

            PendingPayment

            Paid

            Shipped

            Delivered

            Cancelled

            rebuilds state

            5

            returns

            OrderEvents

            +handle(orderId, Event)

            +apply(orderId, Event, Row)

            OrderStore

            +load(id) : Row

            +seen(eventId) : bool

            +commit(id, read, next, Event, effect) : bool

            States

            +fromName(name) : OrderState

            «interface»

            OrderState

            +name()

            +on(event) : Decision

            Row

            +state

            +version

            Event

            +id

            +type

            Decision

            +next

            +effect

            +refusal

            PendingPayment

            Paid

            Shipped

            Delivered

            Cancelled

            Scroll or pinch to zoom, drag to pan
            • Color key
            • Facade
            • Entity
            • Value object
            • Enum
            • Interface
            • Implementation
            Two events race for the same order
            orders tableCustomer handlerWarehouse handlerorders tableCustomer handlerWarehouse handlerPaid.on(ship) gives ShippedPaid.on(cancel) gives CancelledShipped.on(cancel) refusesload A1Paid, v2load A1Paid, v2set Shipped, v3 where version = 21 row, with event id, audit and outboxset Cancelled, v3 where version = 20 rowsload A1 againShipped, v3orders tableCustomer handlerWarehouse handlerorders tableCustomer handlerWarehouse handlerPaid.on(ship) gives ShippedPaid.on(cancel) gives CancelledShipped.on(cancel) refusesload A1Paid, v2load A1Paid, v2set Shipped, v3 where version = 21 row, with event id, audit and outboxset Cancelled, v3 where version = 20 rowsload A1 againShipped, v3
            Scroll or pinch to zoom, drag to pan
            • Color key
            • Database
            Main.javaorder_events.pyProgram.csorder_events.goorder-events.ts
            import java.util.ArrayList;
            import java.util.HashMap;
            import java.util.HashSet;
            import java.util.List;
            import java.util.Map;
            import java.util.Set;
            record Event(String id, String type) {}
            // What the database holds: the state's name and a version that goes up on every change.
            record Row(String state, int version) {}
            // What a state decides for one event: move to `next` and queue `effect`, or refuse.
            record Decision(String next, String effect, String refusal) {
            static Decision go(String next, String effect) { return new Decision(next, effect, null); }
            static Decision refuse(String state, String event) {
            return new Decision(null, null, "cannot " + event + ", order is " + state);
            }
            }
            // Pure: a state looks only at the event. No database, no clock, no network, so deciding
            // again on a retry is harmless. Effects come back as data and go out after the commit.
            interface OrderState {
            String name();
            Decision on(String event);
            }
            class PendingPayment implements OrderState {
            public String name() { return "PendingPayment"; }
            public Decision on(String event) {
            return switch (event) {
            case "pay" -> Decision.go("Paid", "email receipt");
            case "cancel" -> Decision.go("Cancelled", "release stock");
            default -> Decision.refuse(name(), event);
            };
            }
            }
            class Paid implements OrderState {
            public String name() { return "Paid"; }
            public Decision on(String event) {
            return switch (event) {
            case "ship" -> Decision.go("Shipped", "email tracking link");
            case "cancel" -> Decision.go("Cancelled", "refund payment");
            default -> Decision.refuse(name(), event);
            };
            }
            }
            class Shipped implements OrderState {
            public String name() { return "Shipped"; }
            public Decision on(String event) {
            return event.equals("deliver") ? Decision.go("Delivered", null) : Decision.refuse(name(), event);
            }
            }
            class Delivered implements OrderState {
            public String name() { return "Delivered"; }
            public Decision on(String event) { return Decision.refuse(name(), event); }
            }
            class Cancelled implements OrderState {
            public String name() { return "Cancelled"; }
            public Decision on(String event) { return Decision.refuse(name(), event); }
            }
            // Rebuilds the state object from the name stored in the row. States hold no data, so one
            // shared instance of each is enough, and the name is all that needs storing.
            final class States {
            private static final Map<String, OrderState> BY_NAME = new HashMap<>();
            static {
            for (OrderState s : List.of(new PendingPayment(), new Paid(), new Shipped(), new Delivered(), new Cancelled()))
            BY_NAME.put(s.name(), s);
            }
            static OrderState fromName(String name) {
            OrderState s = BY_NAME.get(name);
            // Fail loudly. A name this code doesn't know usually means a newer release wrote it.
            if (s == null) throw new IllegalStateException("unknown state '" + name + "': written by a newer release?");
            return s;
            }
            }
            // Stands in for the database. Each synchronized method is one transaction.
            class OrderStore {
            private final Map<String, Row> rows = new HashMap<>();
            private final Set<String> processed = new HashSet<>();
            final List<String> audit = new ArrayList<>();
            final List<String> outbox = new ArrayList<>();
            synchronized void insert(String id, String state) { rows.put(id, new Row(state, 1)); }
            synchronized Row load(String id) { return rows.get(id); }
            synchronized boolean seen(String eventId) { return processed.contains(eventId); }
            // Compare-and-set, plus everything that must happen with it, in one transaction. In SQL:
            // UPDATE orders SET state = :next, version = version + 1
            // WHERE id = :id AND version = :read -- 0 rows changed: someone moved first
            // INSERT INTO processed_events (event_id) ... -- unique key: a duplicate fails too
            // INSERT INTO order_audit ...; INSERT INTO outbox ...
            synchronized boolean commit(String id, Row read, String next, Event e, String effect) {
            if (rows.get(id).version() != read.version() || processed.contains(e.id())) return false;
            rows.put(id, new Row(next, read.version() + 1));
            processed.add(e.id());
            audit.add("v" + (read.version() + 1) + " " + read.state() + " -> " + next + " by " + e.id());
            // The effect is written, not sent. A relay sends outbox rows after the commit, so a
            // refund can't go out for a transition that lost the race.
            if (effect != null) outbox.add(effect);
            return true;
            }
            }
            class OrderEvents {
            private static final int MAX_ATTEMPTS = 3;
            private final OrderStore store;
            OrderEvents(OrderStore store) { this.store = store; }
            void handle(String orderId, Event e) { apply(orderId, e, store.load(orderId)); }
            // Starts from a row that may already be stale, as it is when two servers read at once.
            void apply(String orderId, Event e, Row row) {
            for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
            // Idempotency: an event already applied is reported as done, not as an error,
            // so the sender stops retrying.
            if (store.seen(e.id())) { report(e, "duplicate, already applied"); return; }
            Decision d = States.fromName(row.state()).on(e.type());
            // A refusal is not recorded: nothing changed, and the same event may be legal later.
            if (d.refusal() != null) { report(e, "refused: " + d.refusal()); return; }
            if (store.commit(orderId, row, d.next(), e, d.effect())) {
            report(e, row.state() + " -> " + d.next() + " (v" + (row.version() + 1) + ")");
            return;
            }
            // Lost the race. Reload and decide again: the new state may refuse this event.
            Row fresh = store.load(orderId);
            report(e, "conflict: read v" + row.version() + ", row is now v" + fresh.version() + "; retrying");
            row = fresh;
            }
            report(e, "gave up after " + MAX_ATTEMPTS + " conflicts");
            }
            private static void report(Event e, String what) { System.out.println(e.id() + " " + e.type() + ": " + what); }
            }
            class Main {
            public static void main(String[] args) {
            OrderStore store = new OrderStore();
            OrderEvents events = new OrderEvents(store);
            store.insert("A1", "PendingPayment");
            events.handle("A1", new Event("evt-1", "pay")); // evt-1 pay: PendingPayment -> Paid (v2)
            // The warehouse asks to ship while the customer cancels. Both handlers read the
            // row before either writes, as two servers would.
            Row warehouseRead = store.load("A1");
            Row customerRead = store.load("A1");
            events.apply("A1", new Event("evt-2", "ship"), warehouseRead);
            // evt-2 ship: Paid -> Shipped (v3)
            events.apply("A1", new Event("evt-3", "cancel"), customerRead);
            // evt-3 cancel: conflict: read v2, row is now v3; retrying
            // evt-3 cancel: refused: cannot cancel, order is Shipped
            // The warehouse's webhook timed out, so it sends evt-2 again.
            events.handle("A1", new Event("evt-2", "ship")); // evt-2 ship: duplicate, already applied
            events.handle("A1", new Event("evt-4", "deliver")); // evt-4 deliver: Shipped -> Delivered (v4)
            for (String line : store.audit) System.out.println("audit " + line);
            // audit v2 PendingPayment -> Paid by evt-1
            // audit v3 Paid -> Shipped by evt-2
            // audit v4 Shipped -> Delivered by evt-4
            System.out.println("outbox: " + String.join(", ", store.outbox));
            // outbox: email receipt, email tracking link
            // A row written by a newer release, with a state this one has never heard of.
            store.insert("B7", "OnHold");
            try {
            events.handle("B7", new Event("evt-5", "cancel"));
            } catch (IllegalStateException ex) {
            System.out.println("error: " + ex.getMessage());
            }
            // error: unknown state 'OnHold': written by a newer release?
            }
            }
            • States became pure deciders. In the Easy level a state called transitionTo itself. Here it can’t, because a change isn’t real until the commit succeeds. on(event) returns a Decision (next state and effect, or a refusal) and touches nothing, so deciding again on a retry is harmless.
            • The version makes the write conditional. UPDATE orders SET state = ?, version = version + 1 WHERE id = ? AND version = ? changes one row or none. None means someone moved first. Reload, and decide again. The retry doesn’t replay the cancel blindly. It asks the new state, and Shipped refuses it.
            • One transaction holds everything that must agree. The new state, the processed event id, the audit row and the outbox row commit together, or not at all. A refund is only written to the outbox. A relay sends it after the commit. So a transition that lost the race never sends one. That’s the outbox pattern, covered with sagas in distributed transactions.
            • Duplicates are successes. The event id is stored with the transition, under a unique key. When the warehouse sends evt-2 again, the answer is “already applied”, which stops its retries. Answering with an error (as the before version does) makes it retry forever or page someone. Two copies racing each other are caught by the same version check.
            • Refusals aren’t recorded. A refused event changed nothing, and it may be legal later. Webhooks arrive out of order. A deliver that beats its ship should fail now, and succeed when it’s delivered again.
            • The state is stored as a name. States.fromName turns the row’s name back into the shared state object. States hold no data, so the name is all there is to store. An unknown name fails loudly. In a rolling deploy, ship the code that can read a new state before any code that writes it. Otherwise old servers will meet OnHold and stop.
            • The audit log is the history. Every transition, its version and the event that caused it. “Why was this order refunded?” is answered by a query, not by guessing from logs.
            Interviewer

            Every few days an order ends up both refunded and shipped. Orders are processed on six servers. Find the bug and fix it.

            What they are testing

            Whether you spot read, decide, write without a condition. Whether you fix it with a version column and a compare-and-set, in the same transaction as the event id, the audit row and the outbox row. And whether you retry by deciding again against the new state. The red flag is a lock inside one service, which doesn’t cover six servers. So is sending the refund before the commit.

            Say this out loud

            The state classes decide. The database makes it real. A handler loads the state name and version, rebuilds the state, and asks it for a decision. It commits with a compare-and-set on the version, together with the event id, the audit row and the outbox. If the version moved, it reloads and asks the new state. Duplicates return “already applied”.

            Use State in three cases. The same action means different things in different situations. There are more than a few states or actions. Or illegal transitions are expensive (money, stock, published content). Keep the enum when there are two or three states, and the actions barely differ. Then a switch the compiler checks for completeness says it all.

            Language Usual form Worth knowing
            Java An interface with default methods that refuse, one class per state An enum whose constants override methods is a compact State when states hold no data
            Python A base class whose methods raise, one subclass per state A dict of (state, event) rows is the table form; the transitions library builds machines from them
            C# An abstract class with virtual methods that throw Switch expressions on (state, event) tuples express a table well; the Stateless library is a common choice
            Go An interface and a struct per state, with defaults from an embedded struct State functions (a function that returns the next function) are idiomatic; the lexer in text/template uses them
            TypeScript Classes, or plain objects that satisfy an interface A discriminated union of states with a switch gets exhaustiveness checks; XState implements statecharts
            Pattern Looks like State because The difference
            Strategy A context delegates to one of several objects behind an interface The client picks a strategy and it stays put. States replace themselves as things happen, and they know their successors.
            An enum with a switch It models the same states The enum is the data and the behavior lives in every method. With few states and actions, and an exhaustive switch, it’s the better choice.
            State machine libraries (XState, Spring Statemachine, Stateless, transitions) They are state machines They implement the table form plus nested and parallel states, history and diagrams. Reach for one when the machine is large or nested; for a dozen states the pattern is enough.
            Workflow or saga (distributed transactions) Orders move through steps A saga coordinates steps across services and compensates when a later step fails. Its own progress is a state machine, but State governs one object’s legal moves.

            On this site:

            • Vending machine: a class per state, because every coin, button and sensor event means something different in each.
            • Parking lot: the ticket and the spot use an enum with a transition table, because their states differ only in which moves are legal.
            • Strategy: the same shape, chosen from outside instead of changing itself.

            In real systems, you’ll find state machines in:

            • TCP connection states (LISTEN, SYN-SENT, ESTABLISHED and the rest).
            • Payment objects, such as Stripe’s PaymentIntent statuses.
            • Kubernetes pod phases.
            • Pull requests (open, merged, closed), and CI jobs.
            • The order, ticket and document workflows in nearly every business application.
            Interviewer asksWhat's the difference between State and Strategy?

            The class diagram is the same. With Strategy, the client chooses an algorithm, and it stays until someone swaps it. Strategies don’t know each other. With State, the current state handles an action, and then picks the next state itself. So the object’s behavior changes as its situation does. Who switches, and why, is the difference.

            Interviewer asksWho should change the state: the context or the states?

            Usually the states: the state that handles an action knows where it leads, so the transition sits next to the behavior. A central transition table in the context shows the whole graph in one place but splits each decision from its effect. Either way, all changes go through one method, so logging and auditing happen once.

            Interviewer asksShould state objects hold data?

            Usually not. Data lives in the context, and states are behavior only. Then one instance of each can be shared, switching is assigning a reference, and a stored state is just its name. A state with its own data (attempts left in Retrying) works, but it can’t be shared and its data has to be stored with it.

            Interviewer asksHow do you persist an object that uses the State pattern?

            Store the state’s name and a version, not the object. On load, map the name back to the state object with a registry. Fail loudly on a name you don’t know. On every transition, write with a compare-and-set on the version. Do it in the same transaction as the audit row, the processed event id and any outbox messages.

            Interviewer asksWhen is an enum and a switch better than State?

            When there are a handful of states and actions, the behavior per state is small, and the language checks that a switch covers every case. A switch is easier to read top to bottom than five classes. Move to State when the switches multiply across methods, or when a missed check would let an illegal move through.

            Decision We chose We gave up
            Where status rules live One class per state behind one interface Seeing one action’s behavior in one place
            Illegal actions Refused by default; a state implements only what’s legal Nothing; it’s the point
            Who picks the next state The state that handled the action A graph you can read in one file, unless you keep a table
            Side effects Entry and exit actions per state, actions per transition A single place to read every effect
            Classes or a table Classes when states do real work, a table when they differ in legality The other one’s strengths
            Stored state A name and a version, rebuilt through a registry Storing data inside state objects
            Concurrent events Compare-and-set per transition, retry by deciding again Simple unconditional writes
            Duplicates and effects Event ids, audit and outbox in the same transaction A bit more schema and a relay to run
            • Strategy, the same shape with a different job
            • Vending machine and Parking lot, one class-per-state design and one table design
            • Distributed transactions and sagas, for the outbox and for workflows that span services
            • Gamma, Helm, Johnson and Vlissides, Design Patterns (1994), the State chapter, whose example is a TCP connection
            • David Harel, “Statecharts: A visual formalism for complex systems” (1987), the origin of the nested and parallel states that statechart libraries implement