Design a Vending Machine
1. Problem & Why It's Asked
The canonical State pattern problem. The naive solution — a pile of booleans (has_money, item_selected, is_dispensing) with if chains checking combinations — allows invalid states to exist in principle (what does is_dispensing=True, has_money=False even mean?). The correct solution makes invalid combinations structurally impossible by giving each state its own class. This is the single most common "why does this pattern exist" teaching example in OOD.
2. Requirements
Functional: accept coins/cash, let a user select an item, dispense the item and correct change, handle insufficient funds and out-of-stock items, allow cancellation with a refund.
Non-functional: invalid operations (e.g., selecting an item before inserting money) must be rejected cleanly, not silently misbehave; extensible to new states (e.g., a maintenance/out-of-service state) without touching unrelated states' logic.
3. State Diagram
stateDiagram-v2
[*] --> Idle
Idle --> HasMoney: insert_coin
HasMoney --> HasMoney: insert_coin
HasMoney --> Dispensing: select_item (sufficient funds, in stock)
HasMoney --> Idle: cancel (refund)
HasMoney --> HasMoney: select_item (insufficient funds - rejected, stays)
Dispensing --> Idle: dispense complete (+ change)
4. Class Diagram
classDiagram
class VendingMachineState {
<<interface>>
+insert_coin(machine, amount)
+select_item(machine, item_id)
+cancel(machine)
}
class IdleState {
+insert_coin(machine, amount)
+select_item(machine, item_id)
+cancel(machine)
}
class HasMoneyState {
+insert_coin(machine, amount)
+select_item(machine, item_id)
+cancel(machine)
}
class DispensingState {
+insert_coin(machine, amount)
+select_item(machine, item_id)
+cancel(machine)
}
class Item {
+str id
+float price
+int stock
}
class VendingMachine {
-VendingMachineState state
-float balance
-dict~str,Item~ inventory
+set_state(state)
+insert_coin(amount)
+select_item(item_id)
+cancel()
}
VendingMachineState <|.. IdleState
VendingMachineState <|.. HasMoneyState
VendingMachineState <|.. DispensingState
VendingMachine --> VendingMachineState
VendingMachine "1" --> "*" Item
5. Implementation
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class Item:
id: str
price: float
stock: int
class VendingMachineState(ABC):
@abstractmethod
def insert_coin(self, machine: "VendingMachine", amount: float) -> None: ...
@abstractmethod
def select_item(self, machine: "VendingMachine", item_id: str) -> None: ...
@abstractmethod
def cancel(self, machine: "VendingMachine") -> None: ...
class IdleState(VendingMachineState):
def insert_coin(self, machine, amount):
machine.balance += amount
machine.set_state(machine.has_money_state)
def select_item(self, machine, item_id):
print("Insert money first")
def cancel(self, machine):
print("Nothing to cancel")
class HasMoneyState(VendingMachineState):
def insert_coin(self, machine, amount):
machine.balance += amount # stay in this state, just add funds
def select_item(self, machine, item_id):
item = machine.inventory.get(item_id)
if item is None or item.stock == 0:
print("Item unavailable")
return
if machine.balance < item.price:
print(f"Insufficient funds — need {item.price - machine.balance:.2f} more")
return # deliberately stays in HasMoneyState
machine.set_state(machine.dispensing_state)
machine.dispense(item)
def cancel(self, machine):
machine.refund(machine.balance)
machine.balance = 0
machine.set_state(machine.idle_state)
class DispensingState(VendingMachineState):
def insert_coin(self, machine, amount):
print("Please wait, dispensing in progress")
def select_item(self, machine, item_id):
print("Please wait, dispensing in progress")
def cancel(self, machine):
print("Cannot cancel mid-dispense")
class VendingMachine:
def __init__(self, inventory: dict[str, Item]):
self.idle_state = IdleState()
self.has_money_state = HasMoneyState()
self.dispensing_state = DispensingState()
self.state: VendingMachineState = self.idle_state
self.balance = 0.0
self.inventory = inventory
def set_state(self, state: VendingMachineState) -> None:
self.state = state
def insert_coin(self, amount: float) -> None:
self.state.insert_coin(self, amount)
def select_item(self, item_id: str) -> None:
self.state.select_item(self, item_id)
def cancel(self) -> None:
self.state.cancel(self)
def dispense(self, item: Item) -> None:
item.stock -= 1
change = self.balance - item.price
self.balance = 0
print(f"Dispensing {item.id}, change: {change:.2f}")
self.set_state(self.idle_state)
def refund(self, amount: float) -> None:
print(f"Refunding {amount:.2f}")
6. Why This Beats the Boolean-Flags Version
The rejected alternative:
# What NOT to do — boolean soup
class VendingMachine:
def __init__(self):
self.has_money = False
self.item_selected = False
self.is_dispensing = False
def select_item(self, item_id):
if not self.has_money:
print("insert money")
elif self.is_dispensing:
print("busy")
# ... every method needs to re-check every flag combination
Every method has to defensively re-check every other flag, and nothing stops has_money=False, is_dispensing=True from existing as a reachable (buggy) combination if a code path forgets to reset a flag. The State pattern's VendingMachine.state field can only ever point at exactly one state object at a time — invalid combinations aren't just avoided by convention, they're unrepresentable.
7. Concurrency
- A single
VendingMachineinstance modeling one physical machine is inherently single-user at a time (only one person interacts with a physical machine simultaneously) — but if this models a fleet of machines behind one software service (e.g., a mobile-app-controlled vending network), each physical machine's state transitions should be locked independently (per-machine lock), not globally — the same per-entity-lock reasoning as [[Parking Lot]] and [[Rate Limiter (OOD)]]. dispense()mutatingitem.stockconcurrently with another request checkingitem.stock == 0inselect_itemhas the same check-then-act race as the Parking Lot's spot allocation — needs the same lock-around-check-and-claim treatment for a shared inventory accessed concurrently.
8. Edge Cases
- Exact change unavailable:
dispense()above assumes the machine can always make change; a real machine needs a coin inventory and should refuse the dispense (refund instead) if it can't return correct change — this is a legitimate follow-up ("what if the machine only has quarters left and owes $0.15?"). - Insert coins mid-transaction, then cancel:
HasMoneyState.cancelrefunds the full accumulatedbalance, not just the last coin — verified by the implementation above trackingbalanceas a running total, not per-coin. - Item goes out of stock while a user has already inserted money but before selecting:
select_item's stock check happens at selection time, not at coin-insertion time — correct, since stock could change between insertion and selection (another concurrent buyer, or a restock).
9. Extensibility
- Maintenance/out-of-service state: add a
MaintenanceStateimplementing the sameVendingMachineStateinterface, where every method prints "out of service" — zero changes toIdleState/HasMoneyState/DispensingState. - Card payment instead of coins:
insert_coinbecomes one of severaladd_fundsentry points (insert_coin,tap_card), both ultimately calling the same balance-mutation logic inHasMoneyState— the state machine's transition logic doesn't change, only how funds enter it. - Multi-item basket (select several items before paying): requires a genuinely new state (
SelectingState) betweenIdleand payment — a good test of whether a candidate understands that new requirements sometimes mean new states, not just new methods on existing states.
10. Interview Drills
- Why is the State pattern better here than an enum + switch statement in one class? — An enum + switch still centralizes all transition logic in one method that must handle every state×action combination, growing combinatorially as states/actions are added. The State pattern distributes each state's valid actions into its own class — adding a state means adding one new class, not editing every existing switch statement.
- A user inserts money, then the machine loses power mid-dispense. What state should it recover into, and why? — This is a durability question, not just an in-memory design one:
balanceandstateneed to be persisted (not just held in Python object attributes) so a restart can recover intoDispensingState(or a dedicatedRecoveringState) and either complete the dispense or refund — silently resetting toIdleStateon restart would swallow the user's money. - How would you test that invalid transitions are actually rejected? — Unit test each state's methods for the actions that shouldn't do anything in that state (e.g.,
DispensingState.insert_coinshould not increase balance) and assert the state doesn't change — the State pattern makes these tests simple because each state class can be tested in isolation.
Cross-links
[[OOD Design Patterns]] · [[SOLID Principles]] · [[Parking Lot]] · [[Elevator System]]