BackendEasy25 min

Design a Vending Machine (LLD)

A state machine in disguise: every coin, button press and refund has exactly one valid outcome in each state.

Say it in 30 seconds
  • 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.

0 of 0 sections ticked
30-second answer

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).

EventAccepted inOutcome
coin (100, 25, 10, 5)Idle, HasMoneyInto escrow; elsewhere it drops to the cup
select(code)HasMoneyDispense, SOLD OUT, "insert 15¢ more", or refund if no change
cancelHasMoneyRefund the escrowed coins
dropped, dropFailedDispensingDrop sensor: commit, or refund
serviceIdle, HasMoney, OutOfServiceToggle out of service (refunds credit first)
restock(code, n), refill(coins)Idle, OutOfServiceAdmin: never mid-purchase
events transition, sale, returned, rejectedFor 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
EventIdleHasMoneyDispensingOutOfService
coinescrow → HasMoneyescrow, stay✕✕
select✕ insert money firstsale → Dispensing¹✕✕
cancel✕refund → Idle✕ too late✕
dropped✕✕commit → Idle✕
dropFailed✕✕refund → Idle✕
service→ OutOfServicerefund → OutOfService✕→ Idle
restockapply✕✕apply
refillapply✕✕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.

Stage 1 of 5

The machine and what it holds

A machine owns its inventory (slots with prices and counts) and a coin box.

Vending machine class diagramVendingMachine 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.Inventoryslots: Map<code, Slot>get(code)take(code)restock(code, n)VendingMachinestate: StateNameescrow: Coin[]pending: { code, price, change }queue: Event[]send(event)refund(why)commit()Slotcode: 'B2'product, price (cents)count, capacitysoldOut: booleanCoinBoxcounts: { 100, 25, 10, 5 }add(coins)remove(coins)with(coins)1..*tubes

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.

Interactive

Vending machine state machine

Coins wait in escrow until the item drops. Insert coins, then click a product.

SNACKS · DRINKSA1 90¢×5A2 110¢×4SOLD OUTA3 120¢×0B1 65¢×5B2 65¢×3B3 70¢×4C1 75¢×5C2 85¢×4C3 55¢×2PUSH · PICKUP0¢INSERT COINSchange OKCOINSA1A2A3B1B2B3C1C2C3REFUNDCHANGE
first coincancel / refundcoin · sold out · shortselect (paid)dropped / no dropserviceservice (refund first)Idle● currentHasMoneyOutOfServiceDispensingEvents light up the transition they take.
✓ Money balanced · 0 sales (0¢)

Idle · 0¢Insert coins, then click a product, or press Run to watch customers. Try a product before paying, or the SOLD OUT slot A3.

Service, restock, jam, coin box
Coin box

Coin box: 2 × 100¢, 8 × 25¢, 8 × 10¢, 8 × 5¢ (520¢) · sales 0 (0¢). ✓ Money balanced: escrow 0¢ + box 520¢ + paid out 0¢ = float 520¢ + inserted 0¢.

Press Play to watch it run · 11 steps

The customer inserts 100¢ and selects B2, a 65¢ cola. The machine checks the slot, reserves 35¢ of change, turns the spiral and waits for the drop sensor before it keeps any money. Pick a branch to see a sold-out slot, too little credit, no possible change, or an item that never drops.

Buy a cola with a dollarThe customer inserts 100¢ and selects B2, a 65¢ cola. The machine checks the slot, reserves 35¢ of change, turns the spiral and waits for the drop sensor before it keeps any money. Pick a branch to see a sold-out slot, too little credit, no possible change, or an item that never drops.Idle, credit 0¢coin(100)escrow [100]: Idle → HasMoneyselect(B2)get(B2)alt [dispensed]Cola, 65¢, 4 leftmakeChange(35, tubes + escrow)[25, 10]Change reserved, nothing paid yet: HasMoney → Dispensingturn spiral B2alt [item dropped]dropped (beam broken)add [100], remove [25, 10]take(B2): 3 leftCola + 35¢ changeDispensing → Idle; telemetry records the sale
  • Client
  • Service
  • Database
  • Cache
  • Worker
Text description
  1. Customer → Machine: coin(100)
  2. Customer → Machine: select(B2)
  3. Machine → Inventory: get(B2)
  4. Inventory replies to Machine: Cola, 65¢, 4 left
  5. Machine → CoinBox: makeChange(35, tubes + escrow)
  6. CoinBox replies to Machine: [25, 10]
  7. Machine → Spiral, sensor: turn spiral B2
  8. Spiral, sensor replies to Machine: dropped (beam broken)
  9. Machine → CoinBox: add [100], remove [25, 10]
  10. Machine → Inventory: take(B2): 3 left
  11. Machine replies to Customer: Cola + 35¢ change

How to approach it

  1. Clarify

    Payment, change, sensor, fleet.

  2. Name the states and events

    Four states, eight events.

  3. Fill in the table

    Unlisted means rejected.

  4. Get the money right

    Cents, escrow, real coins.

  5. Walk a purchase, then break it

    Sold out, no change, a jam.

Read more · 337 words

Step-by-step answer

  1. Events first

    Coin, select, cancel, drop sensor, service, restock, refill.

  2. States

    Idle, HasMoney, Dispensing, OutOfService. Dispensing waits for the sensor and refuses everything else.

  3. 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.

  4. Money is integer cents

    0.1 + 0.2 is 0.30000000000000004. A cola costs 65, not 0.65.

  5. Escrow, then commit

    Coins sit in escrow. dropped moves them into the tubes, pays the change and takes one item off the slot, once. Cancel and dropFailed return the same coins.

  6. 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.

  7. No change, no sale

    DP returns null? Refuse, refund, back to Idle.

  8. One event at a time

    send() runs events in order, so none sees half-updated state.

  9. Payment methods as a strategy

    PaymentMethod has credit(), commit(price) and refund(). A card authorizes at select, captures on dropped, voids otherwise. The table doesn't change.

  10. 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
  • makeChange gets the tubes plus the inserted coins.
  • CoinBox.remove checks 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 breaksEffectMitigation
Item stuckPaid, got nothingNo drop in 2 s: retry once, then refund and flag the slot
Power cut mid-purchaseLost credit or double vendSave every transition; refund on boot unless a drop was logged
Coin jamsCoins swallowedCredit only after the validator confirms
Fake coinFree productsValidator checks size and weight; unknown coins drop to the cup
Tubes run lowCan't pay changeDP check, refuse and refund; low-coins alert
Two buttons at onceDouble vendOne event queue; Dispensing rejects select

Trade-offs

ChoiceProsCons
Table vs switchEvery cell visibleMore structure
Bounded DP vs greedyFinds change when it existsMore code
Escrow vs a credit numberExact refundsTracks coins
Same coins vs equal valueNo change-making on refundNeeds 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 3

Score 2 out of 3 or better to mark this page complete.

1. The tubes hold one 25¢ and three 10¢ coins, and no 5¢. The customer is owed 30¢. What does greedy change-making do?

Patterns behind this

The coding patterns this design is built on. Practise them and the design questions get easier.