What L5 looks like
- The State pattern, with each state owning its rules and safe defaults.
- Escrow, so cancel and failed change refund the exact coins and never corrupt the cash box.
- Sees that greedy change fails with limited stock, and checks change before dispensing.
- Thread safety between customer and operator calls.
Requirements
- Slots (A1, A2, ...) each hold one product with a price and a remaining count.
- A customer selects a slot, then inserts coins and notes until the price is covered.
- Paid in full: dispense the product and return change from the machine's limited coin stock.
- If exact change is impossible, cancel the sale and return every coin inserted.
- Unknown and sold-out slots are rejected at selection. Money inserted before a selection is handed back.
- The customer can cancel any time before dispensing and gets back exactly what they inserted.
- An operator can restock slots and take the machine out of service.
- One customer at a time, but operator and customer calls can race, so state changes are serialized.
- Out of scope: cards and mobile pay, multi-item baskets, promotions, telemetry, motor and coin-validator hardware (assumed to report a valid denomination).
Core objects
-
Denomination(enum): accepted coins and notes, in integer cents (no floating-point money). -
Product(name, price) andSlot(code, product, quantity; knows if it is sold out). -
CashBox: count of each denomination the machine owns; plans change from that limited stock. -
Result: what the customer gets back from an action: product, coins, display message. -
State(abstract): what select, insert and cancel mean right now, with safe defaults. -
IdleState,HasSelectionState,OutOfServiceState: the concrete states. -
VendingMachine: the context. Owns slots, cash box, escrow and current state; holds the lock.
Class design
{"type":"excalidraw","elements":[
{"type":"rectangle","id":"product","x":0,"y":0,"width":180,"height":70,"label":{"text":"Product\nname, price"}},
{"type":"rectangle","id":"slot","x":0,"y":230,"width":180,"height":95,"label":{"text":"Slot\ncode, quantity\nis_sold_out()"}},
{"type":"rectangle","id":"vm","x":340,"y":180,"width":240,"height":190,"label":{"text":"VendingMachine\nslots, escrow,\nselected, state\nselect(), insert(),\ncancel(), restock(),\nset_in_service(),\ncomplete_sale()"}},
{"type":"rectangle","id":"cash","x":760,"y":220,"width":240,"height":110,"label":{"text":"CashBox\ncounts\nadjust(coins, sign)\nplan_change(amount)"}},
{"type":"rectangle","id":"state","x":340,"y":480,"width":240,"height":110,"label":{"text":"State (abstract)\nselect(vm, code)\ninsert(vm, d)\ncancel(vm)"}},
{"type":"rectangle","id":"idle","x":20,"y":760,"width":240,"height":70,"label":{"text":"IdleState"}},
{"type":"rectangle","id":"hassel","x":340,"y":760,"width":240,"height":70,"label":{"text":"HasSelectionState"}},
{"type":"rectangle","id":"oos","x":660,"y":760,"width":240,"height":70,"label":{"text":"OutOfServiceState"}},
{"type":"arrow","id":"vm-slot","x":340,"y":275,"width":-160,"height":0,"start":{"id":"vm"},"end":{"id":"slot"},"label":{"text":"has"}},
{"type":"arrow","id":"vm-cash","x":580,"y":275,"width":180,"height":0,"start":{"id":"vm"},"end":{"id":"cash"},"label":{"text":"has"}},
{"type":"arrow","id":"vm-state","x":460,"y":370,"width":1,"height":110,"start":{"id":"vm"},"end":{"id":"state"},"label":{"text":"uses"}},
{"type":"arrow","id":"slot-product","x":90,"y":230,"width":1,"height":-160,"start":{"id":"slot"},"end":{"id":"product"},"label":{"text":"has"}},
{"type":"arrow","id":"idle-state","x":183,"y":760,"width":209,"height":-170,"start":{"id":"idle"},"end":{"id":"state"},"label":{"text":"extends"}},
{"type":"arrow","id":"hassel-state","x":460,"y":760,"width":1,"height":-170,"start":{"id":"hassel"},"end":{"id":"state"},"label":{"text":"extends"}},
{"type":"arrow","id":"oos-state","x":737,"y":760,"width":-209,"height":-170,"start":{"id":"oos"},"end":{"id":"state"},"label":{"text":"extends"}}
]}
Key flows
Select. VendingMachine.select(code) takes the lock and delegates to the state. IdleState and HasSelectionState (reselection) call vm.choose(code): an unknown or sold-out slot returns a message and leaves the state unchanged; otherwise selected = slot and the state becomes HasSelectionState.
Insert and complete the sale.
-
HasSelectionState.insert(d)appendsdtoescrow: money held, not yet the machine's. - If
paid < price, the display shows the amount still owed. - Otherwise
vm.complete_sale()re-checks stock (an operator call may have changed it), moves escrow into theCashBoxso inserted coins can be used as change, and asksplan_change(paid - price). - No plan: take the escrow back out and refund it. Plan found: remove the change coins, decrement the slot, clear escrow, go to
IdleState.
Cancel. Returns the escrow coins exactly as inserted. The cash box is never touched, so cancel cannot fail.
Change-making. Greedy is optimal for unlimited US coins but fails with limited stock: owing 30 with one quarter and three dimes, greedy takes the quarter and is stuck. plan_change tries the largest count of each denomination first and backtracks; with five denominations this is tiny.
{"type":"excalidraw","elements":[
{"type":"ellipse","id":"start","x":0,"y":50,"width":110,"height":50,"label":{"text":"start"}},
{"type":"rectangle","id":"idle","x":160,"y":0,"width":260,"height":150,"label":{"text":"Idle"}},
{"type":"rectangle","id":"has","x":820,"y":0,"width":260,"height":150,"label":{"text":"HasSelection"}},
{"type":"rectangle","id":"oos","x":160,"y":330,"width":260,"height":80,"label":{"text":"OutOfService"}},
{"type":"arrow","id":"init","x":110,"y":75,"width":50,"height":0,"start":{"id":"start"},"end":{"id":"idle"}},
{"type":"arrow","id":"select","x":420,"y":20,"width":400,"height":0,"start":{"id":"idle"},"end":{"id":"has"},"label":{"text":"select (valid, in stock)"}},
{"type":"arrow","id":"paid","x":820,"y":75,"width":-400,"height":0,"start":{"id":"has"},"end":{"id":"idle"},"label":{"text":"paid >= price / dispense + change"}},
{"type":"arrow","id":"refund","x":820,"y":130,"width":-400,"height":0,"start":{"id":"has"},"end":{"id":"idle"},"label":{"text":"no change or cancel / refund"}},
{"type":"arrow","id":"insert","x":860,"y":0,"width":180,"height":0,"points":[[0,0],[0,-80],[180,-80],[180,0]],"start":{"id":"has"},"end":{"id":"has"},"label":{"text":"insert (paid < price) / reselect"}},
{"type":"arrow","id":"op-off","x":210,"y":150,"width":1,"height":180,"start":{"id":"idle"},"end":{"id":"oos"},"label":{"text":"operator"}},
{"type":"arrow","id":"op-on","x":370,"y":330,"width":1,"height":-180,"start":{"id":"oos"},"end":{"id":"idle"},"label":{"text":"operator"}},
{"type":"arrow","id":"op-refund","x":860,"y":150,"width":-440,"height":220,"start":{"id":"has"},"end":{"id":"oos"},"label":{"text":"operator / refund"}}
]}
Code
from __future__ import annotations
import threading
from abc import ABC
from dataclasses import dataclass, field
from enum import Enum
class Denomination(Enum): # cents; DOLLAR and FIVE are notes
NICKEL = 5; DIME = 10; QUARTER = 25; DOLLAR = 100; FIVE = 500
@dataclass
class Product:
name: str
price: int
@dataclass
class Slot:
code: str
product: Product
quantity: int
def is_sold_out(self) -> bool: return self.quantity <= 0
@dataclass
class Result:
product: Product | None = None
change: list[Denomination] = field(default_factory=list)
message: str = ""
class CashBox:
def __init__(self, counts: dict[Denomination, int]) -> None:
self.counts = {d: counts.get(d, 0) for d in Denomination}
def adjust(self, coins: list[Denomination], sign: int) -> None:
for d in coins:
self.counts[d] += sign
def plan_change(self, amount: int) -> list[Denomination] | None:
denoms = sorted(Denomination, key=lambda d: d.value, reverse=True)
def solve(i: int, left: int) -> list[Denomination] | None:
if left == 0:
return []
if i == len(denoms):
return None
d = denoms[i]
for n in range(min(self.counts[d], left // d.value), -1, -1):
rest = solve(i + 1, left - n * d.value)
if rest is not None:
return [d] * n + rest
return None
return solve(0, amount)
class State(ABC):
def select(self, vm: VendingMachine, code: str) -> Result:
return Result(message="not available")
def insert(self, vm: VendingMachine, d: Denomination) -> Result:
return Result(change=[d], message="select a product first")
def cancel(self, vm: VendingMachine) -> Result:
return Result(message="nothing to cancel")
class IdleState(State):
def select(self, vm, code): return vm.choose(code)
class HasSelectionState(State):
def select(self, vm, code): return vm.choose(code)
def cancel(self, vm): return vm.refund("cancelled", IdleState())
def insert(self, vm: VendingMachine, d: Denomination) -> Result:
vm.escrow.append(d)
owed = vm.selected.product.price - vm.paid()
return vm.complete_sale() if owed <= 0 else Result(message=f"insert {owed}")
class OutOfServiceState(State):
def insert(self, vm, d): return Result(change=[d], message="out of service")
class VendingMachine:
def __init__(self, slots: list[Slot], cash: CashBox) -> None:
self.slots = {s.code: s for s in slots}
self.cash, self.escrow = cash, []
self.selected: Slot | None = None
self.state: State = IdleState()
self._lock = threading.Lock()
def select(self, code: str) -> Result:
with self._lock: return self.state.select(self, code)
def insert(self, d: Denomination) -> Result:
with self._lock: return self.state.insert(self, d)
def cancel(self) -> Result:
with self._lock: return self.state.cancel(self)
def restock(self, code: str, n: int) -> None:
with self._lock: self.slots[code].quantity += n
def set_in_service(self, on: bool) -> Result:
with self._lock:
if not on:
return self.refund("out of service", OutOfServiceState())
self.state = IdleState()
return Result(message="ready")
# helpers below run with the lock held
def paid(self) -> int:
return sum(d.value for d in self.escrow)
def choose(self, code: str) -> Result:
slot = self.slots.get(code)
if slot is None or slot.is_sold_out():
return Result(message="sold out" if slot else "unknown slot")
self.selected, self.state = slot, HasSelectionState()
return Result(message=f"{slot.product.name}: insert {slot.product.price - self.paid()}")
def refund(self, message: str, next_state: State) -> Result:
coins, self.escrow, self.selected = self.escrow, [], None
self.state = next_state
return Result(change=coins, message=message)
def complete_sale(self) -> Result:
slot = self.selected
if slot.is_sold_out():
return self.refund("sold out", IdleState())
self.cash.adjust(self.escrow, +1)
change = self.cash.plan_change(self.paid() - slot.product.price)
if change is None:
self.cash.adjust(self.escrow, -1)
return self.refund("no change available, use exact amount", IdleState())
self.cash.adjust(change, -1)
slot.quantity -= 1
self.escrow, self.selected, self.state = [], None, IdleState()
return Result(product=slot.product, change=change, message="enjoy")
if __name__ == "__main__":
vm = VendingMachine([Slot("A1", Product("Cola", 125), 1)], CashBox({Denomination.QUARTER: 4}))
print(vm.select("A1").message) # Cola: insert 125
vm.insert(Denomination.DOLLAR)
r = vm.insert(Denomination.DOLLAR) # paid 200, owed 75
print(r.message, [d.name for d in r.change]) # enjoy ['QUARTER', 'QUARTER', 'QUARTER']
print(vm.select("A1").message) # sold out
Design patterns
-
State: each state class decides what select, insert and cancel mean, so there is no
if state == ...chain, and a new state (for exampleDispensing) touches no other state. TheStatedefaults are safe (hand the coin back, show a message), so a state only overrides what it allows. - Escrow (transaction): inserted money stays apart from the cash box until the sale commits. Cancel and every failure path return the exact coins inserted. The cash box only changes on a successful sale.
-
Value objects:
Product,ResultandDenominationare plain data, and money is integer cents.
Extensions
-
Card or mobile payment: a
PaymentMethodstrategy. Cash keeps the escrow logic; card authorizes the price, dispenses, then captures, and voids the authorization on a jam. -
Async hardware: add a
DispensingStatethat rejects input while the motor runs. The motor callback goes toIdleon success, or refunds and marks the slot faulty on a jam. The lock covers state changes, not the motor wait. -
Telemetry and restock alerts: the machine publishes
SaleCompleted,SlotSoldOutandLowChangeevents to observers. A fleet service subscribes and plans restock routes; core logic is unchanged. -
Promotions: price comes from a
PricingPolicygiven the slot and time.chooseandcomplete_saleask the policy; the state classes stay the same.
Interviewer expectations
Mid-level (L4)
- Correct classes for product, slot, money and machine, with integer cents.
- A clear select, pay, dispense, change flow that handles sold out and cancel.
- An explicit notion of state, even as an enum with checks.
Senior (L5)
- The State pattern, with each state owning its rules and safe defaults.
- Escrow, so cancel and failed change refund the exact coins and never corrupt the cash box.
- Sees that greedy change fails with limited stock, and checks change before dispensing.
- Thread safety between customer and operator calls.
Staff (L6+)
- Treats the sale as a transaction with hardware: jams, power loss mid-dispense, validator errors, and recovery from a durable log.
- Puts payment, pricing and hardware drivers behind interfaces so one design fits a fleet of machine models.
- Covers fleet operations: sales and low-change events, remote out-of-service, restock planning.
Make it yours
Remix it: write each part yourself, grade it against this answer, then let the coach ask you follow-ups.
