Design a Vending Machine (LLD)
A state machine in disguise: every coin, button press and refund has exactly one valid outcome in each state.
- 32 cells4 states times 8 events, one outcome each
- 1 per 36 sevent rate at a busy office machine
- 65, not 0.65cola price as integer cents, never floats
Show what to say
“This is a state machine in disguise: Idle, HasMoney, Dispensing and OutOfService, with a table that gives every state and event pair exactly one outcome. Money stays in integer cents and in escrow until the drop sensor confirms. I make change with a bounded DP over the real coins in the tubes, and if there's no change, there's no sale.”
Trade-offs
- Table vs switch: every cell is visible and unlisted means rejected, at the cost of more structure for two or three states.
- Bounded DP vs greedy: DP finds change whenever it exists, even with low tubes, for a little more code.
- Escrow vs a credit number: exact refunds and a commit on a confirmed drop, but you track coins, not a total.
Bring it up before they ask
An item that never drops: with no beam break in 2 seconds, retry once, then dropFailed refunds the escrow and flags the slot, so a paying customer never gets nothing.
A state machine in disguise: four states and a table that gives every event one outcome in each. Money is integer cents, held in escrow until the drop sensor confirms.
Functional
- Slots with a product, a price and a count
- Accept coins (notes and cards as extensions)
- Select, dispense, return change
- Cancel and refund before dispensing
- Sold-out slots and an out-of-service mode
- Admin restock and coin refill
Non-functional
- Never dispense without full payment
- Never lose money: every coin is kept for a sale or returned
- Exactly one outcome per event in each state
- New payment methods need no state-logic edits
- Deterministic tests (no clock or randomness in the core)
Out of scope
- Coin validator hardware
- A fleet of machines (an HLD follow-up)
- Dynamic pricing (a follow-up)
A low-level design (LLD) question with two tests: spot the state machine fast, and handle money exactly.
New to this? Read State machines and observers first.
Clarifying questions and interface
Read more · 224 words
- Coins only, or notes and cards?
- Is change paid from the machine's own tubes? (Then it can fail.)
- After SOLD OUT, does the customer keep their credit?
- Does a sensor confirm the item fell?
- One machine, or a fleet?
Scale check
A busy machine sees one event every 36 seconds. A tiny chip copes. The hard part is correctness: one wrong transition hands out a free cola or swallows a dollar.
Public interface
Every input is one event for machine.send(event).
| Event | Accepted in | Outcome |
|---|---|---|
coin (100, 25, 10, 5) | Idle, HasMoney | Into escrow; elsewhere it drops to the cup |
select(code) | HasMoney | Dispense, SOLD OUT, "insert 15¢ more", or refund if no change |
cancel | HasMoney | Refund the escrowed coins |
dropped, dropFailed | Dispensing | Drop sensor: commit, or refund |
service | Idle, HasMoney, OutOfService | Toggle out of service (refunds credit first) |
restock(code, n), refill(coins) | Idle, OutOfService | Admin: never mid-purchase |
events transition, sale, returned, rejected | For display, telemetry and persistence |
What it remembers
Inventory.slots: slot code → product, price (cents), count.CoinBox.counts: coins per denomination, e.g.{ 100: 2, 25: 40, 10: 50, 5: 50 }.escrow: coins for this purchase, still the customer's.pending: the sale between "motor on" and the drop sensor.state: one of four names.
Save state, escrow and pending on every transition, so a power cut can't forget credit.
State transition table
Thirty-two cells, one outcome each. ✕ means rejected: nothing changes.
Read more · 136 words
| Event | Idle | HasMoney | Dispensing | OutOfService |
|---|---|---|---|---|
| coin | escrow → HasMoney | escrow, stay | ✕ | ✕ |
| select | ✕ insert money first | sale → Dispensing¹ | ✕ | ✕ |
| cancel | ✕ | refund → Idle | ✕ too late | ✕ |
| dropped | ✕ | ✕ | commit → Idle | ✕ |
| dropFailed | ✕ | ✕ | refund → Idle | ✕ |
| service | → OutOfService | refund → OutOfService | ✕ | → Idle |
| restock | apply | ✕ | ✕ | apply |
| refill | apply | ✕ | ✕ | apply |
¹ No possible change: refund, back to Idle. Sold out or short credit: stay in HasMoney.
Sold out belongs to a slot, not the machine. The customer keeps their credit.
Is a table worth it? A toy with two states and two events is four cells, so one if is fine. Here, three flags (hasMoney, dispensing, broken) give 8 combinations and only 4 are legal. Miss one guard and a double press of select mid-drop hands out a second cola. A table shows every cell, a missing cell means rejected, and a test can loop over all 32 pairs.
Build the design in stages
Step through: the machine, then state objects instead of flags, then change-making and payment. Green marks what the latest stage added.
VendingMachine owns its inventory and coin box and points at its current state. Each state object (Idle, HasMoney, Dispensing, OutOfService) lists the events it accepts; HasMoney asks ChangeMaker for change before it sells. Payment methods share one interface, and telemetry observes the machine unseen.
- ◆ composition (owns)
- ◇ aggregation
- △ inheritance / implements (dashed)
- → association · ⇢ dependency
Watch it run, and one purchase
Insert coins and press a slot.
Read more · 46 words
Then break it: pick the SOLD OUT slot, set coins to Low and buy C2 (85¢) with $1, or jam an item.
Buy one item, step by step
Play it, then try sold out, not enough credit, can't make change and an item that never drops.
- Client
- Service
- Database
- Cache
- Worker
Text description
- Customer → Machine: coin(100)
- Customer → Machine: select(B2)
- Machine → Inventory: get(B2)
- Inventory replies to Machine: Cola, 65¢, 4 left
- Machine → CoinBox: makeChange(35, tubes + escrow)
- CoinBox replies to Machine: [25, 10]
- Machine → Spiral, sensor: turn spiral B2
- Spiral, sensor replies to Machine: dropped (beam broken)
- Machine → CoinBox: add [100], remove [25, 10]
- Machine → Inventory: take(B2): 3 left
- Machine replies to Customer: Cola + 35¢ change
How to approach it
Clarify
Payment, change, sensor, fleet.
Name the states and events
Four states, eight events.
Fill in the table
Unlisted means rejected.
Get the money right
Cents, escrow, real coins.
Walk a purchase, then break it
Sold out, no change, a jam.
Read more · 337 words
Step-by-step answer
Events first
Coin, select, cancel, drop sensor, service, restock, refill.
States
Idle, HasMoney, Dispensing, OutOfService. Dispensing waits for the sensor and refuses everything else.
The State pattern as a table
Each state maps the events it accepts to the next state. Anything else is rejected.
hasMoney: { select: (m, { code }) => { const slot = m.inventory.get(code); if (slot.soldOut) return no(`${code} is sold out`); const credit = sum(m.escrow); if (credit < slot.price) return no(`Insert ${slot.price - credit}¢ more`); const change = makeChange(credit - slot.price, m.box.with(m.escrow)); if (!change) { m.refund('exact change only'); return 'idle'; } m.pending = { code, price: slot.price, change }; return 'dispensing'; },Every rejection comes before any change, so nothing half-applies.
Money is integer cents
0.1 + 0.2is0.30000000000000004. A cola costs65, not0.65.Escrow, then commit
Coins sit in
escrow.droppedmoves them into the tubes, pays the change and takes one item off the slot, once. Cancel anddropFailedreturn the same coins.Making change
Greedy (biggest coin first) fails when coins run low. Owe 30¢ from one 25¢ and three 10¢: greedy takes the 25¢, then needs a missing 5¢. A bounded DP over the real counts finds 10 + 10 + 10:
for (const d of denoms) { // best[a]: fewest coins for amount a for (let a = 0; a <= amount; a++) for (let j = 0; j <= counts[d] && j * d <= a; j++) if (best[a - j * d] + j < next[a]) { next[a] = best[a - j * d] + j; pick[a] = j; } }Include the escrow: the customer's own quarter can be their change.
No change, no sale
DP returns
null? Refuse, refund, back to Idle.One event at a time
send()runs events in order, so none sees half-updated state.Payment methods as a strategy
PaymentMethodhascredit(),commit(price)andrefund(). A card authorizes at select, captures ondropped, voids otherwise. The table doesn't change.Tests
Loop over all 32 pairs. Add the greedy counterexample, the no-change refund and the failed drop.
Implementation
The same design in JavaScript, Java or Python. Java and Python add real locks.
Read more · 66 words
makeChangegets the tubes plus the inserted coins.CoinBox.removechecks every coin first, so a payout can't half-happen.- The test walks all 32 pairs: 13 accepted, 19 must change nothing.
Code it yourself
Try the Vending Machine exercise: it checks every cell of your state table.
Telemetry for a fleet
Only if asked. Each machine uploads its events, and seq makes a re-sent batch safe.
// Money is integer cents everywhere: 0.1 + 0.2 is 0.30000000000000004 in floating point,
// and a cash box has to balance to the cent.
export const COINS = Object.freeze([100, 25, 10, 5]); // accepted coins, largest first
export const sum = coins => coins.reduce((total, c) => total + c, 0);
export class Slot {
constructor(code, product, price, count, capacity = 10) {
Object.assign(this, { code, product, price, count, capacity });
}
get soldOut() { return this.count === 0; } // a property of one slot, not a machine state
}
export class Inventory {
constructor(slots) { this.slots = new Map(slots.map(s => [s.code, s])); }
get(code) { return this.slots.get(code); }
take(code) {
const slot = this.slots.get(code);
if (!slot || slot.soldOut) throw new Error(`Nothing left in ${code}`);
slot.count--;
}
restock(code, n) {
const slot = this.slots.get(code);
slot.count = Math.min(slot.capacity, slot.count + n);
}
}
export class CoinBox { // the change tubes
constructor(counts = {}) { this.counts = Object.fromEntries(COINS.map(c => [c, counts[c] ?? 0])); }
add(coins) { for (const c of coins) this.counts[c]++; }
remove(coins) {
const need = {};
for (const c of coins) need[c] = (need[c] ?? 0) + 1;
for (const c in need) if (this.counts[c] < need[c]) throw new Error(`Only ${this.counts[c]} × ${c}¢ left`);
for (const c of coins) this.counts[c]--; // all or nothing
}
with(coins) { // counts as if `coins` were added
const counts = { ...this.counts };
for (const c of coins) counts[c]++;
return counts;
}
total() { return COINS.reduce((t, c) => t + c * this.counts[c], 0); }
}CREATE TABLE vend_events (
machine_id text NOT NULL,
seq bigint NOT NULL, -- per machine: re-sent batches are idempotent
at timestamptz NOT NULL,
type text NOT NULL, -- 'sale', 'refund', 'rejected', 'jam', 'restock'
slot text,
amount int, -- cents, never a float
PRIMARY KEY (machine_id, seq)
);Failure modes and trade-offs
Read more · 167 words
| What breaks | Effect | Mitigation |
|---|---|---|
| Item stuck | Paid, got nothing | No drop in 2 s: retry once, then refund and flag the slot |
| Power cut mid-purchase | Lost credit or double vend | Save every transition; refund on boot unless a drop was logged |
| Coin jams | Coins swallowed | Credit only after the validator confirms |
| Fake coin | Free products | Validator checks size and weight; unknown coins drop to the cup |
| Tubes run low | Can't pay change | DP check, refuse and refund; low-coins alert |
| Two buttons at once | Double vend | One event queue; Dispensing rejects select |
Trade-offs
| Choice | Pros | Cons |
|---|---|---|
Table vs switch | Every cell visible | More structure |
| Bounded DP vs greedy | Finds change when it exists | More code |
| Escrow vs a credit number | Exact refunds | Tracks coins |
| Same coins vs equal value | No change-making on refund | Needs an escrow tray |
Follow-up questions
How would you add card payments?
A CardPayment behind PaymentMethod: authorize on select, capture on dropped, void otherwise.
How would you run a fleet of 10,000 machines?
Machines work offline and upload event logs numbered per machine, so re-sends are safe. The centre plans restocks and pushes prices, applied only when Idle.
How would you support dynamic pricing?
A PricingStrategy fed by config. Lock the price into pending at select so it can't change mid-purchase.
How would you add a buy-2 combo?
Make pending a basket. Each dropped commits one item. A failed item refunds its share.
How do you test it?
Table tests over all 32 pairs, plus long random runs that check the money adds up. Nothing random in the core, so failures repeat.
Check yourself
Question 1 of 3Score 2 out of 3 or better to mark this page complete.
Patterns behind this
The coding patterns this design is built on. Practise them and the design questions get easier.