Design a Vending Machine: The State Pattern in Python
8 min readBytePatterns
Design a vending machine for a low-level design interview: one class per state, transitions owned by states, a sold-out state, checked against a flag version.
"Design a vending machine" is a low-level design question that looks like it is about coins and snacks. It is really about one thing: the same button has to mean different things at different moments. Pressing "select" before paying, after paying and when the machine is empty are three different events. The design that handles that cleanly is the state pattern, and the interview is mostly about whether you reach for it and can say when you would not.
The problem it solves
The first version usually has a flag. has_money, then is_sold_out, then is_dispensing, and every method opens with the same ladder of checks:
- The rules are scattered. "What happens on select when sold out" lives in
select; "what happens on a coin when sold out" lives ininsert. No single place describes a state. - Every new state edits every method. Adding "sold out" means one more branch in insert, select, refund and restock.
- Impossible combinations become possible. Two booleans allow four combinations; one of them, "paid and sold out", should never exist, and nothing stops it.
The intuition
Give each state its own small class with the same methods: insert, select, refund, restock. The machine holds one state object and forwards every action to it without looking. Each state answers the actions its own way, and when an action is a transition, the state replaces itself on the machine.
Three rules keep this honest:
- The machine holds data, not rules. Stock, prices and the current balance live on the machine; decisions live in the states.
- Only a state names the next state. Callers never assign
m.state, because only the current state knows which moves are legal from here. - Every state answers every action. "Sold out, coin returned" is an answer, not an exception; the interface is the same for all states, which is what lets the machine stay ignorant.
Adding "sold out" is then one new class plus one changed transition: Paid.select, the only place that can sell the last item, now chooses between Idle and SoldOut. The machine does not change at all.
Watch it run
The animation gives each moment its own small object, and the machine holds one of them at a time. do("select") is forwarded, unexamined, to whichever object is in the socket. Idle simply answers "pay first": no flag was read and nothing changed. Then do("coin") arrives, which is legal here, and Idle is the object that names what comes next. The transition is the socket loading a different object; the machine itself did not change. Now the same call means something else: do("select") reaches Paid, which dispenses. Paid hands the machine back to Idle, so the fourth call answers "pay first" again: one cycle, no branches. The last frame adds SoldOut, a new class plus one new edge out of Paid, and not one line inside the machine.
Vending Machine
Step 1 of 8
Each moment gets its own small object, and the machine holds one of them at a time.
The same interactive animation as the lesson — step through it with the controls.
The code
A machine with prices, stock and a balance, and three states. Paid.select holds the one line that SoldOut added:
class Idle:
def insert(self, m, coin):
m.balance += coin
m.state = Paid()
return "credit %d" % m.balance
def select(self, m, item):
return "pay first"
def refund(self, m):
return "nothing to refund"
def restock(self, m, item, n):
m.stock[item] = m.stock.get(item, 0) + n
return "restocked"
class Paid:
def insert(self, m, coin):
m.balance += coin
return "credit %d" % m.balance
def select(self, m, item):
price = m.prices[item]
if m.stock.get(item, 0) == 0:
return "%s is out, pick another" % item
if m.balance < price:
return "insert %d more" % (price - m.balance)
m.stock[item] -= 1
change, m.balance = m.balance - price, 0
m.state = SoldOut() if sum(m.stock.values()) == 0 else Idle() # the one edge SoldOut added
return "dispense %s, change %d" % (item, change)
def refund(self, m):
back, m.balance = m.balance, 0
m.state = Idle()
return "refund %d" % back
def restock(self, m, item, n):
return "busy"
class SoldOut:
def insert(self, m, coin):
return "sold out, coin returned"
def select(self, m, item):
return "sold out"
def refund(self, m):
return "nothing to refund"
def restock(self, m, item, n):
m.stock[item] = m.stock.get(item, 0) + n
m.state = Idle()
return "restocked"
class Machine:
"""Holds no rules: every action goes to whichever state object it holds."""
def __init__(self, prices, stock):
self.prices, self.stock, self.balance = prices, dict(stock), 0
self.state = Idle() if sum(stock.values()) else SoldOut()
def do(self, action, *args):
return getattr(self.state, action)(self, *args)
m = Machine({"tea": 120, "soup": 150}, {"tea": 1, "soup": 0})
for step in [("select", "tea"), ("insert", 100), ("select", "tea"), ("insert", 50),
("select", "soup"), ("select", "tea"), ("insert", 100), ("restock", "soup", 2),
("insert", 200), ("select", "soup")]:
print(step, "->", m.do(*step), "|", type(m.state).__name__)
# ('select', 'tea') -> pay first | Idle
# ('insert', 100) -> credit 100 | Paid
# ('select', 'tea') -> insert 20 more | Paid
# ('insert', 50) -> credit 150 | Paid
# ('select', 'soup') -> soup is out, pick another | Paid
# ('select', 'tea') -> dispense tea, change 30 | SoldOut
# ('insert', 100) -> sold out, coin returned | SoldOut
# ('restock', 'soup', 2) -> restocked | Idle
# ('insert', 200) -> credit 200 | Paid
# ('select', 'soup') -> dispense soup, change 50 | Idle
While the states are only names, a table is shorter and easier to read in one place. The lesson's two-state machine as a dictionary:
TABLE = { # (state, action) -> (next state, reply)
("idle", "coin"): ("paid", "paid"),
("idle", "select"): ("idle", "pay first"),
("paid", "coin"): ("paid", "already paid"),
("paid", "select"): ("idle", "dispensing"),
}
state, replies = "idle", []
for action in ["select", "coin", "select", "select"]:
state, reply = TABLE[state, action]
replies.append(reply)
print(replies) # ['pay first', 'paid', 'dispensing', 'pay first']
A refactor must not change behaviour. The flag version is kept as a reference and both machines are driven by 5,000 seeded random sequences of up to 30 actions: every reply and every resulting state must match, and the money must balance, since coins in equal change and refunds out plus takings plus the balance still held:
import random
class FlagMachine:
"""Before the refactor: one string flag, and every method branches on it."""
def __init__(self, prices, stock):
self.prices, self.stock, self.balance = prices, dict(stock), 0
self.mode = "idle" if sum(stock.values()) else "sold_out"
def do(self, action, *args):
if action == "insert":
if self.mode == "sold_out":
return "sold out, coin returned"
self.balance += args[0]
self.mode = "paid"
return "credit %d" % self.balance
if action == "select":
if self.mode == "idle":
return "pay first"
if self.mode == "sold_out":
return "sold out"
item = args[0]
if self.stock.get(item, 0) == 0:
return "%s is out, pick another" % item
if self.balance < self.prices[item]:
return "insert %d more" % (self.prices[item] - self.balance)
self.stock[item] -= 1
change, self.balance = self.balance - self.prices[item], 0
self.mode = "sold_out" if sum(self.stock.values()) == 0 else "idle"
return "dispense %s, change %d" % (item, change)
if action == "refund":
if self.mode != "paid":
return "nothing to refund"
back, self.balance, self.mode = self.balance, 0, "idle"
return "refund %d" % back
if action == "restock":
if self.mode == "paid":
return "busy"
self.stock[args[0]] = self.stock.get(args[0], 0) + args[1]
self.mode = "idle"
return "restocked"
NAMES = {"idle": "Idle", "paid": "Paid", "sold_out": "SoldOut"}
random.seed(29)
ok = True
for _ in range(5_000):
prices = {"tea": 120, "soup": 150, "gum": 60}
stock = {k: random.randint(0, 2) for k in prices}
a, b = Machine(prices, stock), FlagMachine(prices, stock)
coins_in = coins_out = takings = 0
for _ in range(random.randint(1, 30)):
action = random.choice(["insert", "select", "refund", "restock"])
args = {"insert": (random.choice([10, 50, 100, 200]),), "select": (random.choice(list(prices)),),
"refund": (), "restock": (random.choice(list(prices)), random.randint(1, 3))}[action]
out = a.do(action, *args)
ok &= out == b.do(action, *args) and type(a.state).__name__ == NAMES[b.mode]
coins_in += args[0] if out.startswith("credit") else 0
coins_out += int(out.split()[-1]) if out.startswith(("dispense", "refund")) else 0
takings += prices[args[0]] if out.startswith("dispense") else 0
ok &= coins_in == coins_out + takings + a.balance # no money appears or vanishes
print(ok) # True
The complexity
- Per action: one attribute lookup and one method call,
O(1), against a chain of flag checks that grows with the number of states. - Adding a state: one class, plus edits only in the states that can transition into it. Here that is
Paid.selectforSoldOutand nothing else. - Cost: more classes, and the full transition map is spread across them. A diagram or a table in the docs pays that back.
Where it goes wrong
- Letting callers set the state. Then any code can put the machine into a state that no transition leads to.
- Keeping a flag "just in case". Two sources of truth drift apart; the state object is the only truth.
- Sharing mutable state objects between machines. Here each transition creates a fresh object, which is safe. If you reuse singletons to save allocations, keep all data on the machine.
- Using the pattern for two labels. The lesson's two-state machine is clearer as a table, as above.
- Forgetting concurrency. Two buyers pressing buttons at once need one lock around
do, or the balance can be spent twice.
When it shows up in interviews
As "design a vending machine", "design an ATM", a traffic light, a document workflow or an order lifecycle: anything whose reaction to an event depends on its history. Interviewers look for states as classes, transitions owned by states, and a clean answer to "add a maintenance mode". It sits next to the strategy pattern, which swaps behaviour chosen from outside, and the elevator design, which is a state machine with a scheduler on top. Products created by type are the factory pattern's job.
How to say it in an interview
"The machine's reaction to a button depends on what happened before, so I'll model states explicitly. Each state is a class with the same methods, insert, select, refund and restock. The machine holds the current state object and forwards every action to it; it keeps stock, prices and balance but no rules. Transitions happen inside the states, so only the current state decides what is legal next. Adding sold-out is one class and one changed transition in Paid. If the states were just labels, I'd use a transition table instead. For concurrent buyers, I'd lock around each action."