Vending Machine - Interview Questions & Answers¶
Target Level: Senior/Staff Engineer (6+ years)
Evaluation Focus: State machines, money handling, failure paths, payment systems, concurrency
Question 1: State Machine Design¶
Interviewer: "Design a vending machine that handles the complete purchase flow using state machines."
🎯 Expected Answer¶
State Diagram:
select(code) enough cash, change OK
┌──────┐ ─────────────────────▶ ┌────────────────────┐ ──────────┐
│ IDLE │ │ AWAITING_PAYMENT │ │ dispense,
│ │ ◀───────────────────── │ │ ◀─────────┘ commit
└──┬───┘ cancel / jam: refund └────────────────────┘ (back to IDLE)
│ ▲ card: authorize → dispense → capture
▼ │ exit
┌────────────────┐
│ OUT_OF_SERVICE │ entered from any state; refunds an open transaction
└────────────────┘
State Pattern with a rejecting base class:
class MachineState(ABC):
def __init__(self, machine): self.m = machine
def select(self, code): raise InvalidStateError(...)
def insert(self, piece): raise InvalidStateError(...)
def pay_by_card(self, tok): raise InvalidStateError(...)
def cancel(self): raise InvalidStateError(...)
class IdleState(MachineState): # overrides select
class AwaitingPaymentState(MachineState): # overrides insert, pay_by_card, cancel
class OutOfServiceState(MachineState): # overrides nothing
Each state overrides only what it allows, so adding a state is one class and illegal actions fail loudly by default.
Why State over if/else? With if state == IDLE ... elif ... in every method, adding OUT_OF_SERVICE means editing every method. Honest caveat: with three states and four actions, a transition table is also fine — say so, then pick one.
✅ Transition Guards¶
| Transition | Guard |
|---|---|
| IDLE → AWAITING_PAYMENT | Slot exists and quantity > 0 |
| AWAITING → vend | Balance ≥ price and change can be made from cash box + escrow |
| AWAITING → IDLE (cancel / jam) | Return exact escrowed pieces; void card hold |
| any → OUT_OF_SERVICE | Refund the open transaction first |
Figure: machine states and guarded transitions.
stateDiagram-v2
[*] --> IDLE
IDLE --> AWAITING_PAYMENT: select (slot exists, qty > 0)
AWAITING_PAYMENT --> IDLE: vend (balance ok, change ok)
AWAITING_PAYMENT --> IDLE: cancel or jam, refund
IDLE --> OUT_OF_SERVICE: any state, refund open txn
AWAITING_PAYMENT --> OUT_OF_SERVICE
OUT_OF_SERVICE --> IDLE: exit
Question 2: Money and Change¶
Interviewer: "How do you represent money, and how do you make change?"
Integer cents. Floats can't represent 0.10 exactly; comparisons and sums drift. Use integer minor units (or Decimal) everywhere; format only at the edge.
Change-making with a limited supply:
def make_change(amount: int, available: Mapping[Denomination, int]) -> Optional[Counter]:
best = {0: Counter()} # reachable amount -> fewest coins
for d in coins_desc(available):
layer = dict(best)
for reached, used in best.items():
for k in range(1, available[d] + 1):
total = reached + k * d.cents
if total > amount: break
cand = used + Counter({d: k})
if total not in layer or size(cand) < size(layer[total]):
layer[total] = cand
best = layer
return best.get(amount) # None -> can't make change
Why not greedy? Greedy is optimal for canonical systems like USD/EUR with unlimited coins. (Canonical doesn't mean "each coin divides the next" — 25 isn't a multiple of 10 — it means greedy happens to be optimal for every amount.) A machine's coin tubes are limited, and then greedy fails even for USD: 30c from one quarter and three dimes. Non-canonical systems (1, 3, 4) break greedy even with unlimited supply.
When change is impossible: reject the piece that overpays before accepting it, keep the transaction open, and light "exact change only". Don't vend and then short-change.
Question 3: Payment Integration¶
Interviewer: "Add card payments."
class PaymentGateway(ABC):
def authorize(self, token, amount_cents) -> str: ... # hold, may decline
def capture(self, auth_id) -> None: ...
def void(self, auth_id) -> None: ...
Flow: authorize → dispense → capture; void on jam. Charging first and refunding on failure means a real charge, a refund that takes days, and fees. Cash and card share the same states; they differ only in how the sale commits. Don't let a customer mix: once cash is in escrow, pay_by_card is rejected (or define split tender explicitly).
Follow-ups:
- Gateway times out during authorize? Send an idempotency key; on timeout, retry with the same key or query by it. Never authorize twice blind.
- Machine crashes between dispense and capture? Journal AUTHORIZED and DISPENSED to local storage before each step; recover on boot (capture dispensed, void the rest). Uncaptured holds also expire at the issuer.
Figure: card purchase is authorize, dispense, capture; void on a jam.
sequenceDiagram
participant M as Machine
participant G as Gateway
M->>G: Authorize (idempotency key)
G-->>M: Hold placed
M->>M: Journal AUTHORIZED, dispense
alt drop sensor fires
M->>M: Journal DISPENSED
M->>G: Capture
else jam
M->>G: Void authorization
end
Question 4: Concurrency & Thread Safety¶
Interviewer: "Only one person can stand at the machine. Why do you need a lock?"
Because the keypad isn't the only caller:
- Operator/telemetry thread: restock, cash collection, maintenance mode run while a customer is mid-transaction.
- Remote purchases (app/QR): two buyers can race for the last unit.
One lock per machine, held for each public operation. The race to show:
# Broken: check-then-act across two calls
if vm.stock()["A1"] > 0: # both buyers see 1
vm.purchase(...) # both vend; stock goes to -1
purchase_with_card does select + pay under one RLock acquisition, so the stock check and the decrement are atomic. Test: 16 threads buy from a slot of 3 → exactly 3 succeed, 3 captures, no dangling holds.
Lock held across a network call? Yes, deliberately: the machine is a serial device and the call is ~1–2 s. The alternative is reserve → unlock → authorize → relock → commit, which needs a RESERVED state and expiry — worth it for a shared resource with many buyers (ticketing), not for one machine.
Question 5: Failure Handling¶
| Failure | Correct behaviour |
|---|---|
| Motor jam / drop sensor sees nothing | Cash: return escrow. Card: void. Stock unchanged |
| Can't make change | Reject the overpaying piece; transaction stays open |
| Card declined | Stay in AWAITING_PAYMENT (or reset for app purchase) |
| Customer walks away | Timeout → cancel → return escrow |
| Maintenance mid-transaction | Refund, then go out of service |
| Restock beyond capacity | Reject (ValueError), don't silently clamp |
| Power loss | Escrow is physical — returned on boot; journal resolves card holds |
Question 6: Testing Strategy¶
- Fake the ports:
Jammeddispenser,FakeGateway(records holds/captures/voids, declines tokens starting withdeclined). - Pure-function tests for
make_change: greedy-breaking case, fewest coins, impossible, notes never paid out. - Flow tests per path: exact cash, overpay with change, change from escrow, rejected piece, cancel returns exact pieces, jam refunds, card capture/decline/void, maintenance refund.
- Accounting invariants: cash box grows by exactly the price; stock decrements only on a confirmed drop.
- Concurrency: many threads racing for the last units → no oversell, no dangling authorizations; restock racing with sales →
final = initial + restocked − soldand never above capacity.
Question 7: Inventory at Fleet Scale¶
Interviewer: "Design the inventory system for a vending machine chain."
- The machine is the source of truth for its slots; the cloud is eventually consistent via an append-only event stream (
SOLD,RESTOCKED,COUNTED). - Restock alerts on
quantity <= reorder_level; route planning from forecasted depletion (see HLD). - The operator's physical count at restock is authoritative and resets drift (theft, mis-loads).
- Expiry per slot load (not per product), FIFO by loading newer items behind older.
❌ Common Mistakes¶
- Money as
float. - Greedy change with a limited coin supply; or vending first and discovering "insufficient change" afterwards.
cancel()that zeroes a balance instead of returning the inserted pieces.- Charging the card before dispensing.
- Decrementing stock before the drop is confirmed.
- States that silently
printon illegal actions instead of raising — callers (and tests) can't tell anything went wrong. - Claiming "thread-safe" with a check (
is_available) and an act (dispense) in separate unlocked calls. PaymentStrategyclasses that "process" a payment by printing and returningTrue— no failure path modelled at all.
🎚️ Senior vs Staff Signal¶
- Senior: clean state machine with typed errors; integer money; correct change-making with limited supply; escrow; cancel/jam refund; a lock that covers check-and-act; tests with fakes.
- Staff: drives the design from failure modes (jam, crash between dispense and capture, gateway timeout, lost acks) and the order of operations that keeps money safe; two-phase card payment with idempotency keys and a recovery journal; knows when not to add a reservation state; frames fleet inventory as machine-authoritative event streams with idempotent ingestion.