State¶
Intent¶
Let an object behave differently as its lifecycle moves, without a growing if self.status == ... ladder in every method. Each state is an object that accepts the events legal for it, rejects the rest, and moves the context to its successor. The transitions become explicit, drawable and testable, which is what the interviewer really wants once an entity has three or more statuses.
When to use and when not to¶
Use it when
- The entity has three or more statuses and the legal events differ per status: a vending machine, an order, an ATM session, a traffic light.
- The same event means different things in different states:
disableon an idle machine switches it off; mid-dispense it must refund first. - Illegal events must be rejected loudly, with a message naming the current state.
- The follow-up "what if they press cancel while it is dispensing" should be answered by one method on one class.
Leave it out when
- Two states and a boolean covers it (
is_open). - Only a label varies, not behaviour: a status enum plus a transition table is enough.
- The client picks the variant and it never moves by itself: that is Strategy.
- The transitions live in a workflow engine or another service; model the table, not the classes.
Structure¶
Three roles: the Context that owns the data, a State base class that rejects every event, and one subclass per state that accepts its events and triggers the transition.
classDiagram
class MachineState {
+name: str
+insert_money(machine: VendingMachine, amount: Money) None
+select(machine: VendingMachine, code: str) None
+dispense(machine: VendingMachine) Tuple~str,Money~
+cancel(machine: VendingMachine) Money
+disable(machine: VendingMachine) Money
+enable(machine: VendingMachine) None
}
class Idle {
+insert_money() None
+disable() Money
}
class HasMoney {
+insert_money() None
+select() None
+cancel() Money
}
class Dispensing {
+dispense() Tuple~str,Money~
+disable() Money
}
class OutOfService {
+enable() None
}
class Slot {
+product: str
+price: Money
+quantity: int
}
class VendingMachine {
-_state: MachineState
-_balance: Money
-_selected: str
-_slots: Dict~str,Slot~
-_lock: Lock
+transitions: List~Tuple~
+insert_money(amount: Money) None
+select(code: str) None
+dispense() Tuple~str,Money~
+cancel() Money
+transition_to(state: MachineState) None
+reserve(code: str) None
+release() Tuple~str,Money~
+refund() Money
}
MachineState <|-- Idle
MachineState <|-- HasMoney
MachineState <|-- Dispensing
MachineState <|-- OutOfService
VendingMachine o-- MachineState : current
VendingMachine "1" *-- "many" Slot
MachineState ..> VendingMachine : transition_to
MachineState rejects every event with InvalidStateError; each subclass overrides only the cells of the state-event matrix it accepts, so the matrix is readable from the class bodies. States hold no data, so a fresh Idle() per transition is correct and cheap.
The same machine as a lifecycle: every arrow is one overriding method, every missing arrow is a rejection.
stateDiagram-v2
[*] --> Idle
Idle --> HasMoney : insert_money
HasMoney --> HasMoney : insert_money
HasMoney --> Dispensing : select, once reserve succeeds
HasMoney --> Idle : cancel, refund
Dispensing --> Idle : dispense, product and change
Dispensing --> OutOfService : disable, refund first
Idle --> OutOfService : disable
OutOfService --> Idle : enable
Canonical example in Python¶
The states come first (code/patterns/state.py, tested by code/patterns/tests/test_state.py):
class MachineState:
"""Base state: every event is rejected here; a subclass overrides the ones it accepts.
States hold no data of their own (balance, stock and the selection live on the
machine), so a fresh instance is created at each transition and nothing needs
resetting. Each handler receives the machine because it is the machine's data
it works on, and it is the machine's ``transition_to`` that moves things along.
"""
@property
def name(self) -> str:
return type(self).__name__
def _reject(self, event: str) -> InvalidStateError:
return InvalidStateError(f"cannot {event} while {self.name}")
def insert_money(self, machine: VendingMachine, amount: Money) -> None:
raise self._reject("insert money")
def select(self, machine: VendingMachine, code: str) -> None:
raise self._reject("select")
def dispense(self, machine: VendingMachine) -> tuple[str, Money]:
raise self._reject("dispense")
def cancel(self, machine: VendingMachine) -> Money:
raise self._reject("cancel")
def disable(self, machine: VendingMachine) -> Money:
raise self._reject("disable")
def enable(self, machine: VendingMachine) -> None:
raise self._reject("enable")
class Idle(MachineState):
"""Nothing inserted: accepts money, or the operator taking the machine offline."""
def insert_money(self, machine: VendingMachine, amount: Money) -> None:
machine.add_balance(amount)
machine.transition_to(HasMoney())
def disable(self, machine: VendingMachine) -> Money:
machine.transition_to(OutOfService())
return Money(0)
class HasMoney(MachineState):
"""Credit on the display: more coins, a selection, or a refund."""
def insert_money(self, machine: VendingMachine, amount: Money) -> None:
machine.add_balance(amount) # a self-transition: the state object stays
def select(self, machine: VendingMachine, code: str) -> None:
machine.reserve(code) # raises on a bad code, an empty slot or a short balance: no transition
machine.transition_to(Dispensing())
def cancel(self, machine: VendingMachine) -> Money:
refund = machine.refund()
machine.transition_to(Idle())
return refund
class Dispensing(MachineState):
"""The motor is running: only the hardware's confirmation or a fault moves us on."""
def dispense(self, machine: VendingMachine) -> tuple[str, Money]:
product, change = machine.release()
machine.transition_to(Idle())
return product, change
def disable(self, machine: VendingMachine) -> Money:
refund = machine.refund() # a jam: give the money back, then take the machine offline
machine.transition_to(OutOfService())
return refund
class OutOfService(MachineState):
"""Offline: accepts nothing except the operator bringing it back."""
def enable(self, machine: VendingMachine) -> None:
machine.transition_to(Idle())
Three decisions to say out loud:
- The base class rejects; subclasses accept. Adding
OutOfServiceto anif/elifdesign touches every event method; here it is one new class and two overrides. The rejection names the state, which is the message a test asserts. - Validate before you transition.
HasMoney.selectasks the machine toreservethe slot first; a bad code, an empty slot or a short balance raises, and the machine stays inHasMoneywith its balance intact. The transition is the last statement of a handler, never the first. - States are stateless. Balance, stock and the selection live on the machine, so states never need resetting.
Dispensingreads the selected code from the machine, which is why the machine is passed into every handler.
The context does two jobs, holding the data and serialising events:
@dataclass(slots=True)
class Slot:
"""One product tray: the data the states operate on."""
product: str
price: Money
quantity: int
class VendingMachine:
"""The Context: owns the data and the current state, and delegates every event to it.
``_lock`` guards ``_state``, ``_balance``, ``_selected`` and the slots. An event
is check-and-transition inside one critical section, so two threads racing to
``select`` cannot both reach ``Dispensing``. The helpers below the events are
called by states from inside an event, with the lock already held, and do not
take it again.
"""
def __init__(self, slots: Mapping[str, Slot]) -> None:
self._slots = dict(slots)
self._state: MachineState = Idle()
self._balance = Money(0)
self._selected: str | None = None
self._lock = threading.Lock()
self.transitions: list[tuple[str, str]] = []
@property
def state_name(self) -> str:
with self._lock:
return self._state.name
@property
def balance(self) -> Money:
with self._lock:
return self._balance
def quantity(self, code: str) -> int:
with self._lock:
return self._slots[code].quantity
# -- events: one delegation each, under the lock ----------------------------------
def insert_money(self, amount: Money) -> None:
if amount.cents <= 0:
raise ValidationError("insert a positive amount")
with self._lock:
self._state.insert_money(self, amount)
def select(self, code: str) -> None:
with self._lock:
self._state.select(self, code)
def dispense(self) -> tuple[str, Money]:
with self._lock:
return self._state.dispense(self)
def cancel(self) -> Money:
with self._lock:
return self._state.cancel(self)
def disable(self) -> Money:
with self._lock:
return self._state.disable(self)
def enable(self) -> None:
with self._lock:
self._state.enable(self)
# -- called by states, lock already held -------------------------------------------
def transition_to(self, state: MachineState) -> None:
self.transitions.append((self._state.name, state.name))
self._state = state
def add_balance(self, amount: Money) -> None:
self._balance += amount
def reserve(self, code: str) -> None:
slot = self._slots.get(code)
if slot is None:
raise NotFoundError(f"no slot {code}")
if slot.quantity == 0:
raise ConflictError(f"{slot.product} is sold out")
if self._balance < slot.price:
raise ValidationError(f"insert {slot.price - self._balance} more for {slot.product}")
self._selected = code
def release(self) -> tuple[str, Money]:
if self._selected is None:
raise InvalidStateError("nothing was selected")
slot = self._slots[self._selected]
slot.quantity -= 1
change = self._balance - slot.price
self._balance, self._selected = Money(0), None
return slot.product, change
def refund(self) -> Money:
refund, self._balance, self._selected = self._balance, Money(0), None
return refund
Each public event is one delegation under _lock, so check-and-transition is atomic: sixty-four threads racing to select produce exactly one Dispensing, which is the concurrency test. The helpers below the events run with the lock already held and must not lock again. transitions records every move as a pair for tests to assert.
Running python -m patterns.state prints:
--- happy path: coins in, a selection, the motor confirms ---
insert 1.00 USD -> HasMoney, balance 1.00 USD
insert 1.00 USD -> HasMoney, balance 2.00 USD
select A1 (cola 1.50) -> Dispensing
dispense -> Idle, tray: cola, change 0.50 USD
--- every other event is refused by the state, not by an if-ladder in the machine ---
select A1 rejected: cannot select while Idle (still Idle)
dispense rejected: cannot dispense while Idle (still Idle)
insert 0.50 USD -> HasMoney, balance 0.50 USD
select B2 (sold out) rejected: chips is sold out (still HasMoney)
select A1 (cola 1.50) rejected: insert 1.00 USD more for cola (still HasMoney)
cancel -> Idle, refunded 0.50 USD
--- maintenance: only an idle machine can be taken offline ---
disable -> OutOfService
insert 1.00 USD rejected: cannot insert money while OutOfService (still OutOfService)
enable -> Idle
transitions: Idle -> HasMoney -> Dispensing -> Idle -> HasMoney -> Idle -> OutOfService -> Idle
--- the same lifecycle as an Enum and a transition table ---
idle + insert -> has_money
has_money + select -> dispensing
dispensing + dispense -> idle
idle + dispense -> rejected: cannot dispense while idle
has_money + select with 0.50 USD against 1.50 USD -> rejected: insert 1.00 USD more
Pythonic variant¶
When the per-state behaviour is a line or two, four classes are ceremony. The whole lifecycle fits in a table, and an Enum plus a table is the form most production code uses:
class Status(StrEnum):
IDLE = "idle"
HAS_MONEY = "has_money"
DISPENSING = "dispensing"
OUT_OF_SERVICE = "out_of_service"
class Event(StrEnum):
INSERT = "insert"
SELECT = "select"
DISPENSE = "dispense"
CANCEL = "cancel"
DISABLE = "disable"
ENABLE = "enable"
# The whole lifecycle on one screen; a missing key *is* the rejection.
TRANSITIONS: Final[Mapping[tuple[Status, Event], Status]] = MappingProxyType(
{
(Status.IDLE, Event.INSERT): Status.HAS_MONEY,
(Status.IDLE, Event.DISABLE): Status.OUT_OF_SERVICE,
(Status.HAS_MONEY, Event.INSERT): Status.HAS_MONEY,
(Status.HAS_MONEY, Event.SELECT): Status.DISPENSING,
(Status.HAS_MONEY, Event.CANCEL): Status.IDLE,
(Status.DISPENSING, Event.DISPENSE): Status.IDLE,
(Status.DISPENSING, Event.DISABLE): Status.OUT_OF_SERVICE,
(Status.OUT_OF_SERVICE, Event.ENABLE): Status.IDLE,
}
)
def next_status(status: Status, event: Event) -> Status:
"""A pure function: the table decides, the caller keeps the data."""
try:
return TRANSITIONS[(status, event)]
except KeyError:
raise InvalidStateError(f"cannot {event} while {status}") from None
def next_status_guarded(status: Status, event: Event, *, balance: Money, price: Money) -> Status:
"""``match`` for the one transition that carries a guard; the rest still comes from the table."""
match status, event:
case Status.HAS_MONEY, Event.SELECT if balance < price:
raise ValidationError(f"insert {price - balance} more")
case _:
return next_status(status, event)
- The table is the specification. A reviewer reads eight lines and sees every legal move; the state diagram above is a rendering of this mapping. A missing key is the rejection, so there is no default branch to forget.
matchwhen a transition has a guard.next_status_guardedkeeps the table for the unguarded cells and adds the balance check to one case. Guards are where classes start earning their keep again, because a guard needs data the table does not hold.- The data stays with the caller.
next_statusis pure; a machine in this style keepsstatus: Statusand assigns the result after its own validation.StrEnumprints and persists as the string the database column holds.
| Reach for | When |
|---|---|
| A boolean | Two states, no illegal event worth naming |
Enum + transition table |
Three or more states, thin behaviour, a table that must be reviewed or persisted |
match on (status, event) |
A few guarded transitions on top of the table |
| One class per state | Entry and exit actions, per-state data, or handlers long enough to want their own tests |
Draw the state diagram first whichever form you write; it is the artefact the interviewer checks your code against.
Real-world usage¶
- TCP is the textbook machine (
LISTEN,SYN-SENT,ESTABLISHED,TIME-WAIT): the same segment means different things per state, and the kernel implements the table. http.client.HTTPConnectiontracks idle, request-started and request-sent, and raisesCannotSendRequestorResponseNotReadyfor out-of-order calls: the enum-plus-rejection form in the standard library.concurrent.futures.Futureandasyncio.Future: pending, running, cancelled, finished;set_resulton a finished future raisesInvalidStateError, the same error name this handbook uses.- Workflow engines: a persisted
statuscolumn plus a transition table; django-fsm's@transition(source, target)and AWS Step Functions definitions are reviewable tables.
Related patterns and confusions¶
| Looks like State | How to tell them apart |
|---|---|
| Strategy | The same picture, a context delegating to a swappable object, so ask who switches. With Strategy the client picks a rule up front and the rules do not know each other exist; with State the object switches itself as events arrive, and each state knows its successors. |
| Template Method | Varies the steps of one algorithm by subclassing, fixed when the class is written. State varies the whole response to an event, at runtime, by swapping the delegate. |
| Memento | State swaps behaviour as the lifecycle moves; Memento swaps data back to an earlier snapshot. A memento may well contain the status enum. |
| Chain of Responsibility | One current object handles every event and switches itself; nothing is forwarded along a line. |
| A status field with checks | if self.status == Status.IDLE inside every method is the ladder this pattern removes. The table form centralises the checks; the class form deletes them. |
Where it appears in LLD problems¶
- Design a vending machine (and a coffee machine) — these four states are the core.
- Design an ATM — a six-state session; the PIN-attempt counter lives on the session, not the state.
- Design a traffic signal controller — timed
RED,GREEN,YELLOWwith a no-conflicting-greens guard. - Design an elevator system — idle, moving and doors-open per car.
- Design a movie ticket booking system (BookMyShow) — a seat held, booked or released on a timer.
- Design Amazon (cart, order, inventory, payment) — the order lifecycle as a persisted table.
- Design a payment gateway and digital wallet — payment statuses with idempotent retries.
Interview tips¶
Interview tip
Draw the state diagram before the class diagram, and put the rejections on it as well as the arrows: "cancel while dispensing is refused; disable while dispensing refunds first." Then say which form you would write, "an Enum and a table, promoted to classes if a state grows entry actions or its own data", and name the test: every (state, event) pair, legal ones move, the rest raise InvalidStateError.
Common mistake
if self.status == Status.HAS_MONEY: repeated inside every method. It is the ladder with extra steps: transitions are scattered, a new state touches every method, and nobody can see which pairs are legal. Put the pairs in one place, a table or a class per state. Runner-up: transitioning before validating, so a failed selection leaves the machine in Dispensing with nothing to dispense.
Related¶
- Strategy — the delegate the client switches
- Template Method — variation by subclassing, fixed when the class is written
- Design a vending machine (and a coffee machine) — this machine inside a full problem
- Design an ATM — a six-state session with the same shape
- Design a traffic signal controller — timed transitions with a safety guard
- Gamma, Helm, Johnson and Vlissides, Design Patterns (1994), State
- RFC 9293 — Transmission Control Protocol (TCP), state machine overview
- Python documentation:
enum—StrEnum