InterviewPrepKit

Home / Cheat Sheet / Object-Oriented Design

Cheat sheet

How to design a vending machine

Read the full lesson →

A vending machine is a state machine plus a change-making algorithm: the same button sells, refuses, or waits depending on state you cannot see from outside.

The five requests (nothing returns a value)

  • insert_coin(cents), select(code), cancel() come from the customer; dispensed() and refunded() come from the hardware.
  • Output is observable change to four places, not a return value: tray, coin_return, slot count, and the transitions log. Test by asserting on those.
  • Hardware is an actor. Because the motor takes time and can jam, DISPENSING must exist. Events arriving from the machine are what make this a state machine, not a function.

State pattern, by the numbers

  • Five states, five events: 5 x 5 = 25 (state, event) cells. Only 6 move the machine; the other 19 are the same polite refusal.
  • State version = 5 base defaults + 6 overrides = 11 methods. The if/elif version writes all 25 branches across 5 methods with nothing enforcing completeness.
  • Crossover: at 2 states if/elif wins; at 3+ states the State pattern wins.
  • Asymmetry: State makes a new state cheap (one class, touches nothing) and a new event expensive (a base method plus a decision in every subclass).
  • Cost of State: behaviour is no longer readable top to bottom; select is spread across five classes.

The six transitions that move the machine:

IDLE       --insert_coin-->  HAS_MONEY
HAS_MONEY  --insert_coin-->  HAS_MONEY   (stay, credit += c)
HAS_MONEY  --select------->  DISPENSING  (funds ok, in stock)
HAS_MONEY  --cancel------->  REFUNDING
DISPENSING --dispensed---->  IDLE
REFUNDING  --refunded----->  IDLE

Escrow makes cancel total

  • Escrow: coins held aside, belonging to nobody yet; a list[int] field, not a class. Banked only at commit; handed back on cancel.
  • An operation is total when defined for every input and can never fail. Escrow makes cancel total: there is no coin arrangement that stops the machine returning the exact metal just inserted.
  • No escrow means cancel pays from the bank and can fail for lack of change. Escrow removes that whole failure mode.
  • HasMoney.select runs its three raises (OutOfStock, InsufficientFunds, ExactChangeOnly) before the first write, so a refusal never eats credit or moves state.
  • credit (total cents, for price checks) and escrow (individual coins, for exact refunds) are both needed, not redundant.

Exact change is a predicate, not a state

  • “Exact change only” is computed on demand from CoinBank: does any change amount the machine can owe go unpayable? Recomputed after each transaction.
  • Modelling it as a state would double the count (each of 5 gets an exact-change twin = 10).
  • Rule: if you can compute a condition from data you already hold, it is not a state. A state is something the machine must remember.
  • Derive the swept amounts from payable payments minus stocked prices; never a hard-coded 5..95, which mishandles a 63-cent price.

Monitor, not Singleton

  • The bug is a read-decide-write race on credit: a coin lands between the button thread’s read and its write, and the 25c vanishes silently.
  • Fix: make the machine a monitor, one RLock taken by every public method, so one thread is inside at a time. RLock (re-entrant) only because a future state method might call back in.
  • Singleton is the wrong tool: it guarantees one-per-process (not the stated invariant), hides dependencies, and its lazy-init is itself a race. Instead construct one machine in main() and inject it.
  • Shared stateless State objects are flyweights, not singletons: one immutable instance shared because there is nothing to tell users apart.
  • Caveat: the lock guards methods, not fields. Public state, credit, pending, bank.counts are a free snack in three assignments.

Change-making: greedy is wrong

  • Greedy (biggest coin that fits, never reconsider) is optimal only on canonical coin systems, and even then breaks on a finite hopper.
  • Use bounded-coin-change dynamic programming (make_change): fewest coins from a limited supply; returns None when unpayable, which becomes “exact change only”.
  • Cost: O(amount x total_coins) time, O(amount x denominations) memory. Effectively free at vending scale (100 cents, 4 denominations).
  • Escrow coins join the bank copy in plan_change(extra=...) so a customer’s own coins can pay her change; that is why select deposits before it withdraws.
CaseHopper / coinsGreedyOptimal (DP)
Canonical, finite{25:1, 10:3}, pay 30None (took the quarter, stuck){10:3}
Non-canonical{1,3,4}, pay 63 coins (4+1+1)2 coins (3+3)
US, deep hopper{25,10,5,1} deep, pay 41{25,10,5,1}same

Gotchas

  • Validate the coin denomination at the boundary, before the lock: an unchecked -35 reaches CoinBank and makes make_change raise IndexError forever.
  • Decrement stock at commit, not at dispense; otherwise a power cut leaves the count disagreeing with reality.
  • OutOfStock and ExactChangeOnly refuse and leave credit untouched; a machine that eats your dollar wrote the rejection as a transition instead of a refusal.
  • Totality has a cost: a deliberately refused pair and a forgotten override look identical from outside (both a courteous rejection).
  • State machine assumes: the state set is closed and known, all (state, event) pairs are defined, and one state captures the whole situation. Power failure, maintenance mode (needs a return address, i.e. hierarchical states), and card payment (independent axes multiply the count) are where these strain.
Want the full picture? The lesson has the derivations, worked examples, and diagrams this card compresses into bullets. Read the full lesson →
Report a bug