Concepts / Behavioral patterns / State machines and the State pattern

State machines and the State pattern

Behaviour that depends on a mode, without a wall of conditionals.

01The intuition

The problem it solves

A vending machine program has a few modes: waiting for a coin, holding money, dispensing, out of order. Every button press means something different in each mode. The obvious code keeps a mode string and checks it inside every method, so each method is a wall of conditions and each new mode means editing all of them. A bug hides easily: press "refund" while dispensing and the machine both drops the snack and returns the coins.

class Machine:
    def __init__(self) -> None:
        self.mode = "idle"
        self.balance = 0

    def press(self, button: str) -> None:
        if self.mode == "idle":
            if button == "coin": self.balance += 10; self.mode = "paid"
        elif self.mode == "paid":
            if button == "coin": self.balance += 10
            elif button == "select": self.mode = "dispensing"; self.drop()
            elif button == "refund": self.give_back(); self.mode = "idle"
        elif self.mode == "dispensing":
            if button == "refund": self.give_back()   # bug: still dispensing
        # "broken" mode? nobody remembered to handle it here

Build it one piece at a time

Step one: draw the modes as circles and the button presses as arrows between them on paper. This picture is a state machine. Write down for each mode which arrows exist; every pair with no arrow is an illegal event, and the program should refuse it rather than guess.

Step two: give each mode its own class. Each class has one method per event and decides what happens and which mode comes next. The machine itself keeps only the current mode object and the shared data, such as the balance.

class State:
    def coin(self, m: "Machine") -> "State": raise IllegalEvent("coin")
    def select(self, m: "Machine") -> "State": raise IllegalEvent("select")
    def refund(self, m: "Machine") -> "State": raise IllegalEvent("refund")

class Idle(State):
    def coin(self, m: "Machine") -> State:
        m.balance += 10
        return Paid()

class Paid(State):
    def coin(self, m: "Machine") -> State:
        m.balance += 10
        return self
    def select(self, m: "Machine") -> State:
        m.drop()
        return Dispensing()
    def refund(self, m: "Machine") -> State:
        m.give_back()
        return Idle()

Step three: the machine forwards every event to the current state and stores whatever state comes back. The if-else wall is gone; the only check left is the one the state class makes by existing.

class Dispensing(State):
    def done(self, m: "Machine") -> State:
        m.balance = 0
        return Idle()

class Machine:
    def __init__(self) -> None:
        self.state: State = Idle()
        self.balance = 0
    def coin(self) -> None:   self.state = self.state.coin(self)
    def select(self) -> None: self.state = self.state.select(self)
    def refund(self) -> None: self.state = self.state.refund(self)

Pressing refund while dispensing now raises IllegalEvent from the base class, because Dispensing never defined a refund arrow. The bug could not be written.

Why it works

The principle: when behaviour depends on a mode, put each mode's behaviour in one place and make the transitions explicit. The invariant is that the machine is always in exactly one state, and every event either moves it along a drawn arrow or is rejected. Where it breaks: shared data that only makes sense in one state (a selected item while idle), transitions hidden inside helper methods so the diagram no longer matches the code, or an enum plus a dictionary of transitions when the states carry no behaviour, which is simpler and just as good. Interviewers test this by firing events in a wrong order and by asking you to add a new state; both should be a few lines.

The trade-off you must say out loud

Four modes become four classes, and a simple two-mode flag does not need any of this. The State pattern pays off when events are many and modes carry different logic; a transition table pays off when modes are many and logic is thin. The follow-up is almost always about concurrency: two threads firing events at once must not both read the old state, so the forwarding method takes a lock or events go through a queue.

Where it shows up in the 100 Google problems

  • Vending machine: the example above, with inventory and change calculation added to the shared data.
  • Elevator controller: idle, moving up, moving down, doors open; a hall call is an event whose effect depends on the state.
  • Stopwatch: stopped, running and paused, with start, pause, resume and reset as the only legal arrows.
  • Movie ticket booking: a seat goes free, held, booked, released; the hold expiring is an event too.
  • Payment processor: created, authorised, captured, refunded, failed, with exactly-once transitions.

02Used in these problems