Four buttons, a coin slot and a return tray. The textbook problem for the State pattern, with one trap the textbooks skip: making change from the coins actually in the box.
The vending machine is the standard machine-coding problem for one pattern: State. The interviewer is checking whether you can see that "what does pressing select do?" has four different answers depending on what's happened so far, and whether you put those answers in four small classes instead of one method with a growing if/elif chain. It's also a good problem for showing judgement: the State pattern is worth it here, and you should be able to say exactly why.
This lesson builds the states, the machine they drive, and the change-making that most candidates get wrong, then runs a few customers through it.
The State pattern: Idle, HasMoney, Dispensing and SoldOut each answer insert, select, cancel and item-dropped, while the machine holds the data and one lock. A customer inserts ₹10, ₹5 and ₹10, selects ₹20 chips, and change, stock and money are checked before the sale; coins pushed in while dispensing go to the tray. Change comes from the coins in the box: ₹6 from a ₹5 and three ₹2 coins is 2 + 2 + 2, where greedy gets stuck, and with no way to make change the machine refuses.
How the round runs#
"Design a vending machine. It takes ₹1, ₹2, ₹5 and ₹10 coins and ₹20 notes, sells a few products from coded slots, gives change, and lets you cancel. If it can't give change, it shouldn't sell. Make it run. 90 minutes."
- 0–10 min — clarify: denominations, change rules, cancel, what happens while dispensing, sold out
- 10–25 min — draw the state diagram first: states, events, transitions
- 25–40 min — products, slots, the cash box, and change-making from limited coins
- 40–65 min — the
Stateclasses and the machine (the context) - 65–80 min — a demo: a normal sale, too little money, a cancel, no change possible, sold out
- 80–90 min — follow-ups: UPI payments, a maintenance mode, telemetry, power loss
Requirements and clarifying questions#
| You ask | They answer | What it changes |
|---|---|---|
| Which coins and notes? | ₹1, ₹2, ₹5, ₹10 coins; ₹20 notes. Reject anything else. | A denomination list; unknown coins go straight to the tray |
| Can a customer add money after selecting? | Yes; selecting with too little just says how much more. | Selecting in "has money" may stay in "has money" |
| Change from what? | Only the coins in the machine, plus the ones just inserted. | A cash box with counts, and bounded change-making |
| Can't make change? | Refuse the sale and say "exact amount only". | Check change before dispensing |
| Cancel? | Return the coins that were inserted. | Inserted coins wait in an escrow, separate from the cash box |
| Input while dispensing? | Ignore buttons; return coins. | A "dispensing" state that refuses everything |
Draw the states before any code
List the states, and for each one say what insert coin does. Where does the machine go after the item drops?
One strong answer
- Idle: insert → add to balance, go to Has money
- Has money: insert → add to balance, stay
- Dispensing: insert → return the coin at once (the motor is running)
- Sold out: insert → return the coin
After the item drops: back to Idle if any slot still has stock, else Sold out. Four states and four events is sixteen answers. That is the moment the State pattern pays off: each state is a class that answers all four events, and the machine never asks "which state am I in?"
%% title: Vending machine: states and events stateDiagram-v2 [*] --> Idle Idle --> HasMoney: insert coin HasMoney --> HasMoney: insert coin / too little / sold-out slot HasMoney --> Idle: cancel, coins returned HasMoney --> Dispensing: select, paid, change possible Dispensing --> Idle: item dropped, stock left Dispensing --> SoldOut: item dropped, all empty SoldOut --> Idle: refill
The classes#
%% title: Vending machine: the classes
classDiagram
class VendingMachine {
+slots dict
+cash dict
+escrow list
+returned list
+state State
+insert(coin) str
+select(code) str
+cancel() str
+item_dropped() str
+refill(code, n)
}
class State {
<<abstract>>
+insert(m, coin) str
+select(m, code) str
+cancel(m) str
+item_dropped(m) str
}
class Slot {
+product Product
+count int
}
class Product {
+code str
+name str
+price int
}
VendingMachine --> State
VendingMachine "1" *-- "many" Slot
Slot --> Product
State <|-- Idle
State <|-- HasMoney
State <|-- Dispensing
State <|-- SoldOutThe machine is the context: it holds the data (slots, cash box, escrow, return tray) and forwards each event to its current state. Each state decides what the event means and which state comes next. The base State answers every event with a polite refusal ("coin returned", "can't select now"), so each concrete state overrides only the events it actually handles.
The interfaces#
Products, slots, and change-making. Read the docstring: with unlimited Indian coins, greedy change (largest coin first) is always optimal. With a limited cash box it isn't: to give ₹6 from one ₹5 and three ₹2 coins, greedy takes the ₹5 and then can't make ₹1, while 2 + 2 + 2 works. make_change is a small bounded-knapsack search over the coins actually present, returning the fewest coins or None.
# vending.py, part 1: products, money, and change-making from a limited cash box
from __future__ import annotations
import threading
from abc import ABC, abstractmethod
from dataclasses import dataclass
DENOMINATIONS = (1, 2, 5, 10, 20) # rupees: coins, plus the ₹20 note
@dataclass(frozen=True)
class Product:
code: str
name: str
price: int
@dataclass
class Slot:
product: Product
count: int
def make_change(amount: int, cash: dict[int, int]) -> dict[int, int] | None:
"""Fewest coins that make `amount` using only what is in the box, or None.
Greedy fails here: ₹6 from {5: 1, 2: 3} is 2 + 2 + 2, and greedy grabs the 5 first."""
best: dict[int, dict[int, int]] = {0: {}}
for coin, have in sorted(cash.items(), reverse=True):
nxt = dict(best)
for total, combo in best.items():
for k in range(1, have + 1):
t = total + k * coin
if t > amount:
break
c = {**combo, coin: k}
if t not in nxt or sum(c.values()) < sum(nxt[t].values()):
nxt[t] = c
best = nxt
return best.get(amount)The states#
Each state is a few lines. The interesting one is HasMoney.select: check the slot, check the balance, then work out the change before committing anything, counting the customer's own coins as available. Only if all three pass does it commit the sale and move to Dispensing.
# part 2: the states. Each one answers every event; most answers are "not now"
class State(ABC):
name = "?"
def insert(self, m: VendingMachine, coin: int) -> str:
m.returned.append(coin)
return f"{self.name}: coin returned"
def select(self, m: VendingMachine, code: str) -> str:
return f"{self.name}: can't select now"
def cancel(self, m: VendingMachine) -> str:
return f"{self.name}: nothing to cancel"
def item_dropped(self, m: VendingMachine) -> str:
return f"{self.name}: ignored sensor event"
class Idle(State):
name = "IDLE"
def insert(self, m: VendingMachine, coin: int) -> str:
m.escrow.append(coin)
m.state = HasMoney()
return f"balance ₹{m.balance}"
def select(self, m: VendingMachine, code: str) -> str:
return "insert money first"
class HasMoney(State):
name = "HAS_MONEY"
def insert(self, m: VendingMachine, coin: int) -> str:
m.escrow.append(coin)
return f"balance ₹{m.balance}"
def select(self, m: VendingMachine, code: str) -> str:
slot = m.slots.get(code)
if slot is None or slot.count == 0:
return f"{code} sold out, pick another or cancel"
if m.balance < slot.product.price:
return f"{slot.product.name} is ₹{slot.product.price}, add ₹{slot.product.price - m.balance}"
box = dict(m.cash)
for c in m.escrow: # the customer's own coins can be part of their change
box[c] = box.get(c, 0) + 1
change = make_change(m.balance - slot.product.price, box)
if change is None:
return "can't make change: exact amount only"
m.commit(slot, box, change)
m.state = Dispensing()
return f"dispensing {slot.product.name}"
def cancel(self, m: VendingMachine) -> str:
m.returned.extend(m.escrow) # give back the very coins that went in
m.escrow.clear()
m.state = Idle()
return "cancelled, coins returned"
class Dispensing(State):
name = "DISPENSING"
def item_dropped(self, m: VendingMachine) -> str:
m.state = Idle() if any(s.count for s in m.slots.values()) else SoldOut()
return f"done, now {m.state.name}"
class SoldOut(State):
name = "SOLD_OUT"
def select(self, m: VendingMachine, code: str) -> str:
return "sold out"The patterns, and why#
| Pattern | Where | Why it earns its place |
|---|---|---|
| State | Idle, HasMoney, Dispensing, SoldOut |
Behaviour depends on state for every event. Each state is one small class; adding "maintenance mode" is a new class, not a new branch in four methods. |
| Context | VendingMachine |
Holds data and the current state, forwards events, and owns the one lock. It contains no business rules. |
| Template default | the base State refuses everything |
A new state handles only the events it cares about, and an unhandled event is a safe refusal, never a crash. |
| Strategy (optional) | change-making | If the operator wants "keep ₹10 coins for change", make_change becomes a strategy. Say it; don't build it unasked. |
Concurrency and edge cases#
A vending machine has one customer at a time, but not one thread: the coin acceptor, the keypad, the drop sensor and a remote refill or telemetry call all fire independently. One lock in the machine serialises every event, so a refill can't change a slot's count halfway through a sale, and a coin can't land between "change computed" and "change paid out".
- An unknown coin (a foreign coin, a washer): straight to the tray, no state change
- Too little money: say how much more; stay in "has money"
- The selected slot is empty but others aren't: say so; keep the money; let them pick again
- No change possible: refuse the sale with "exact amount only"; never short-change silently
- A coin inserted while dispensing: return it; the motor is running
- The item never drops (a jam): a timeout in
Dispensingthat refunds the price and flags the slot - Power cut mid-sale: persist the state and escrow, so a restart returns the customer's coins
The code#
The machine, the context: data, the lock, and one-line forwards to the state.
# part 3: the machine (the context). It holds data; the current state decides what an event means
class VendingMachine:
def __init__(self, slots: list[Slot], cash: dict[int, int]) -> None:
self.slots = {s.product.code: s for s in slots}
self.cash = dict(cash) # coins the machine owns
self.escrow: list[int] = [] # coins inserted for the current sale, not yet the machine's
self.returned: list[int] = [] # what falls into the return tray
self.state: State = Idle() if any(s.count for s in slots) else SoldOut()
self._lock = threading.Lock() # button presses, the coin acceptor and remote refills race
@property
def balance(self) -> int:
return sum(self.escrow)
def commit(self, slot: Slot, box: dict[int, int], change: dict[int, int]) -> None:
slot.count -= 1
for coin, n in change.items():
box[coin] -= n
self.returned.extend([coin] * n)
self.cash = {c: n for c, n in box.items() if n}
self.escrow.clear()
def insert(self, coin: int) -> str:
if coin not in DENOMINATIONS:
self.returned.append(coin)
return "unknown coin returned"
with self._lock:
return self.state.insert(self, coin)
def select(self, code: str) -> str:
with self._lock:
return self.state.select(self, code)
def cancel(self) -> str:
with self._lock:
return self.state.cancel(self)
def item_dropped(self) -> str: # the drop sensor saw the item fall
with self._lock:
return self.state.item_dropped(self)
def refill(self, code: str, n: int) -> None:
with self._lock:
self.slots[code].count += n
if isinstance(self.state, SoldOut):
self.state = Idle()
def take_tray(self) -> list[int]:
out, self.returned = self.returned, []
return outCustomers:
# part 4: a few customers
if __name__ == "__main__":
vm = VendingMachine(
[Slot(Product("A1", "Masala chips", 20), 2), Slot(Product("B2", "Mango drink", 35), 1)],
cash={5: 1, 2: 3},
)
print(vm.select("A1")) # insert money first
print(vm.insert(10), "|", vm.insert(5), "|", vm.select("A1")) # balance 15: add ₹5
print(vm.insert(10), "|", vm.select("A1")) # balance 25: dispensing, change ₹5
print(vm.insert(10), vm.item_dropped(), vm.take_tray()) # coin returned while busy; tray [5, 10]
print(vm.insert(20), "|", vm.insert(20), "|", vm.select("B2")) # ₹40 for 35: change 5 from the box
print(vm.item_dropped(), vm.take_tray())
print(vm.insert(10), vm.select("A1"), "| cash:", vm.cash) # 10 for 20: add ₹10
print(vm.cancel(), vm.take_tray()) # cancelled, [10]
vm2 = VendingMachine([Slot(Product("C3", "Biscuits", 14), 5)], cash={5: 1, 2: 3})
print(vm2.insert(20), "|", vm2.select("C3"), vm2.take_tray()) # change ₹6 = 2+2+2; greedy takes the 5 and gets stuck
vm3 = VendingMachine([Slot(Product("C3", "Biscuits", 14), 5)], cash={5: 1})
print(vm3.insert(20), "|", vm3.select("C3")) # can't make change: exact amount only
print(vm.select("A1"), vm.insert(20), vm.select("A1"), vm.item_dropped()) # last chips: SOLD_OUT
print(vm.insert(5), vm.take_tray()) # SOLD_OUT returns coinsinsert money first
balance ₹10 | balance ₹15 | Masala chips is ₹20, add ₹5
balance ₹25 | dispensing Masala chips
DISPENSING: coin returned done, now IDLE [5, 10]
balance ₹20 | balance ₹40 | dispensing Mango drink
done, now IDLE [5]
balance ₹10 Masala chips is ₹20, add ₹10 | cash: {2: 3, 10: 2, 20: 2}
cancelled, coins returned [10]
balance ₹20 | dispensing Biscuits [2, 2, 2]
balance ₹20 | can't make change: exact amount only
insert money first balance ₹20 dispensing Masala chips done, now SOLD_OUT
SOLD_OUT: coin returned [5]Follow one line at a time: ₹25 for ₹20 chips gives ₹5 change, and a coin pushed in during dispensing comes straight back. The biscuits line is the change-making trap: ₹6 change as three ₹2 coins, because greedy would have taken the only ₹5 and got stuck. The next machine has only a ₹5, so it refuses. Selling the last packet moves the machine to SOLD_OUT, which returns every coin.
Extending it: the follow-ups#
- "Accept UPI." A
PaymentMethodinterface:CoinPayment(today's escrow) andUpiPayment, which shows a QR code and waits for the payment confirmation. A newAwaitingUpistate handles "paid" and "timed out". The existing states don't change. - "Add a maintenance mode." A
Maintenancestate, entered with an operator key, that refuses customers and allows refills and cash collection. - "Report stock and cash remotely." Observer: the machine publishes
sold,sold_outandlow_changeevents; a telemetry client sends them home. - "Different products, different prices by time of day." A
PricingStrategyread byHasMoney.select. - "Survive a power cut." Persist the state name, the escrow and the slot counts after every transition, and on start-up go to
Idleand return the escrow.
Common mistakes#
- One
VendingMachinewithif self.state == "IDLE": ... elif ...in every method - States that hold data (the balance in
HasMoney), so switching state loses money; keep data in the context - Greedy change from a limited box, which fails or short-changes
- Dispensing before checking change, then discovering it can't give any
- Putting the inserted coins straight into the cash box, so "cancel" can't return the same coins
- New state objects with no way back: every state must have an exit for every event, even if the exit is "refuse"
Vending machine
Q1/4 The machine holds one ₹5 coin and three ₹2 coins. A customer needs ₹6 change. What does greedy change-making do?
Answer & explanation
Answer: B
Greedy is optimal for unlimited Indian coins but not for a limited box. The bounded search finds 2 + 2 + 2.
Q2/4 In the State pattern here, where does the customer's inserted money live?
Answer & explanation
Answer: B
States are behaviour; the context holds data. If the balance lived in HasMoney, moving to another state would lose it.
Q3/4 A coin is inserted while the machine is in DISPENSING. What should happen?
Answer & explanation
Answer: C
The motor is running and the sale is committed. The Dispensing state refuses insert by returning the coin.
Q4/4 The interviewer adds a maintenance mode. What changes in this design?
Answer & explanation
Answer: D
That's the point of State: a new mode is a new class answering the same events. Nothing else is edited.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.