Design a Vending Machine

Design a vending-machine LLD: session states, inventory reserve, coin box, change, and the jam path that must not swallow Alice’s cash or sell the last bag twice.

Page content

Alice walks up with a ₹10 note and a ₹5 coin. Chips in slot A4 cost ₹12. She inserts both, presses A4, and the coil turns. The bag catches on the spiral. The motor times out. The tray is empty.

If you already moved her ₹15 into the vault and decremented A4 to zero, she has neither chips nor change. The machine ate a sale that did not happen.

If you wait to decrement until a successful drop, the other keypad on this hallway machine — or Alice’s double-press — can sell the last bag twice. Bob’s select(A4) also saw qty = 1. Two motors, one bag.

This is an LLD interview: a session, a cash box, and one reserved item. It is not a smart-fridge fleet and it is not a payments platform.

The main design question is:

How do we accept money, reserve one item, dispense, and return change without a double-vend or swallowed cash?

We will start with if money >= price then vend and watch Alice lose ₹15. Escrow, a hold on the last bag, a change check, and a jam refund appear when that if is no longer enough. Same last-unit race as Design a Parking Lot. The ATM cousin — bank ledger plus a local cassette — is Design an ATM.

1. Clarify the problem

“Design a vending machine” can mean a snack spiral or a city of connected coolers. The questions that change the objects:

  • Coins only, or coins and notes?
  • One item per session, or a cart?
  • Must we give change, or exact-change only?
  • What happens when the hopper cannot make change?
  • Sold-out: reject the press, or take the money and fail?
  • Can she cancel after inserting and get the same notes back?
  • One keypad, or two faces sharing one cabinet?
  • Cash only, or card tap too?

If the interviewer gives no extra constraints, I would state:

Money               Coins and notes: ₹1, ₹2, ₹5, ₹10, ₹20
Session             One item, then settle
Flow                Pay, then select (common snack machine)
Change              Required when we can make it
Cannot make change  Abort the select, refund, light EXACT CHANGE
Sold-out            Reject; do not take a new payment for it
Cancel              Allowed while money is still in escrow
Cabinet             One inventory; two keypads are two sessions
Card / age gate     Out of scope for v1; same reserve + dispense

Telemetry to a warehouse, dynamic pricing, and a phone app that “unlocks a fridge” are a different interview. Say so and stay on this cabinet.

2. Functional requirements

The system must:

  1. Accept coins and notes into a session and show a running balance.
  2. Let Alice pick one in-stock slot whose price is covered.
  3. Dispense that item and return change.
  4. Reject sold-out and insufficient-funds selections without taking extra money.
  5. Cancel from the money-collecting state and refund the session.
  6. Never vend the last bag twice, and never keep cash for a failed drop.

Not in v1: multi-item carts, coupons, planograms from a server, card authorization as the source of truth. Those sit on the same session and inventory. They do not change the reserve.

3. Non-functional requirements

This is not a 50k QPS problem. Interviewers want a state machine and a hold, not a cluster.

RequirementTarget
Last itemAtomic reserve; no double-vend
Insert / select / cancelMilliseconds; one process
CashInteger rupees (or paise); never a float
ConcurrencyTwo keypads, one remaining bag
ClockMachine clock for jam timeout and idle refund

One qty unit moves to RESERVED for this session, or it does not. Durability is optional until they ask to persist a sales log. A jammed bag that never dropped is not a sale.

Edge cases

  • Alice inserts ₹15, selects ₹12 chips, the coil jams.
  • Alice and Bob both press the last bag on a two-face cabinet (or she double-presses).
  • Hopper has only ₹10 notes; she is owed ₹3.
  • She inserts ₹10 for a ₹12 item, then cancels — or idles out with escrow still loaded.
  • Slot A4 is empty; the lamp still looks lit.
  • Exact-change lamp is on; she inserts a ₹20 for a ₹12 item.

4. Scale

Say the numbers so nobody spends the interview on QPS.

Slots                 20 – 60 in one cabinet
Qty per slot          0 – 15 bags
Keypads               1, or 2 faces sharing inventory
Inserts / minute      a few at a busy station
Sessions / day        hundreds, not billions

A scan of 60 slots is fine. The bug is two sessions reading qty = 1. Ten thousand cabinets is more of this design, not a log. Restock is off the vend path.

Station hallway
  ├── Cabinet 14  face A   ← this design (one Inventory)
  │               face B   ← second Session, same Inventory
  └── Cabinet 15           ← this design

5. Entities / what we store

Seven facts. The session is the lock on cash. The hold is the lock on the bag.

Product        id, name, price
Slot           id, productId, qty, reserved, status OK | JAMMED | DISABLED
Inventory      slots; reserve / release / commit
Coin           denomination, count
CoinBox        escrow[], hopper[d], vault[d]
ChangeMaker    canMake(amount), make(amount) -> coins | empty
Session        id, face, state, inserted[], reservedSlot?, balance
VendingMachine faces share one Inventory and one CoinBox
VendResult     item?, change[], status

Escrow is money Alice just put in. It is still hers until a drop succeeds. The hopper is the tubes we make change from. The vault is operator money after a successful sale. If escrow and vault are the same pile, a jam refund is “hope the hopper can recreate ₹15.” That is how cash gets swallowed.

A session is the source of truth for “this face has Alice’s ₹15 and maybe a hold on A4.” Slot qty is stock you update in the same critical section as the hold. The sold-out lamp is a count you can recompute from qty - reserved. If the lamp and the slot disagree, the slot wins.

IDLE
  insert ₹10, ₹5
ACCEPTING_MONEY   escrow = [10, 5]   A4 qty=1 reserved=0
  select A4
ITEM_SELECTED     A4 reserved=1      escrow still hers
  motor + drop sensor
DISPENSING
  success
  vault += escrow, hopper pays ₹3, A4 qty=0 reserved=0
IDLE

Session id (s_9f3a) is the lock on this face; A4 is what she pressed. Faces share slots. The product name is a label.

6. APIs or class methods

In LLD you may show a controller, not HTTP. Either is fine.

insert(face, money) -> SessionView
select(face, slotId) -> VendResult
cancel(face) -> Refund
status(face) -> SessionView

Insert

Request:

POST /v1/machines/m_14/faces/A/insert
{ "denomination": 10 }

Response:

200
{ "session_id": "s_9f3a", "state": "ACCEPTING_MONEY", "balance": 10, "escrow": [10] }

A second insert of ₹5 yields balance: 15. Insert in IDLE opens a session. Insert after a successful vend is a new session. Insert during DISPENSING is rejected; the motor owns the cabinet face.

Select

Request:

POST /v1/machines/m_14/faces/A/select
{ "slot_id": "A4" }

Response (ok, then the machine dispenses):

200
{ "status": "VENDED", "slot_id": "A4", "price": 12, "change": [2, 1] }

Response (cannot make change):

409
{ "error": { "code": "CANNOT_MAKE_CHANGE", "message": "Insert exact or cancel" },
  "refund": [10, 5], "exact_change_lamp": true }

Sold-out is 409 SOLD_OUT and escrow is untouched. Insufficient funds is 409 INSUFFICIENT_FUNDS with the current balance. Already reserved for this session is the same VendResult or 409 IN_PROGRESS — pick one and keep select from reserving twice.

Cancel

POST /v1/machines/m_14/faces/A/cancel
{}
200
{ "refund": [10, 5], "state": "IDLE" }

Cancel is legal from ACCEPTING_MONEY. After a reserve, cancel must release A4 and refund. During DISPENSING, cancel is ignored; the drop sensor decides.

The sold-out lamps can read status. The motor must not. Assignment of the last bag goes through select(), not through a count Alice glanced at.

7. Start with a naive if money >= price — show both failures

The first design everyone writes:

function select(slot):
  if session.balance >= slot.price and slot.qty > 0:
    dispense(slot)
    session.balance -= slot.price
    slot.qty -= 1
    return makeChange(session.balance)
  throw REJECT

It is easy to draw and easy to break. Two ways, depending on when you decrement.

Decrement first, then spin the coil

A4 qty = 1
Alice: balance 15 >= 12, qty = 0, eat ₹15, motor jams
Alice: no bag, no ₹15, sold-out lamp on

You committed a sale the physical world refused. That is swallowed cash.

Decrement after a successful drop

A4 qty = 1

Face A  Alice: see qty 1, spin coil
Face B  Bob:   see qty 1, spin coil
Both bags cannot exist. One motor vends air.
qty goes 1 → 0 → -1, or both think they won.

You sold the last unit twice. Same bug as two parking gates and one compact stall: read, then write, with no claim in between.

The naive function also has no cancel, no “can I make ₹3?”, and no escrow. makeChange after you already took the ₹15 is too late if the hopper is empty.

naive vend, no lock          wrong (double-vend)
naive vend, decrement first  wrong (jam eats cash)
naive vend, lot mutex only   race-safe, still swallows on jam
reserve + escrow + then vend the interview answer

A mutex around select fixes the race and is what you say for one keypad. A Senior answer still names when money and qty move.

8. Pay then select vs select then pay

Two legal machines. Pick one out loud and keep every method on that story.

Pay then select (my default, snack spirals):

IDLE → insert* → select → dispense → change → IDLE
         └── cancel ↗

She can feed a note before she has decided. The display shows a balance. Sold-out is a reject at press time. This matches what people already know.

Select then pay (some drink machines, many ticket kiosks):

IDLE → select (hold) → insert until price → dispense → IDLE
         └── cancel releases the hold

The hold starts earlier. That is nicer for the last bag: Bob cannot take A4 while Alice is still fishing for a ₹5. The cost: an abandoned hold needs a timeout, and the first press on an empty slot does nothing useful.

I would not mix them. If money can arrive before a hold and a hold can exist before money, you now have two “current item” fields and a cancel matrix. Pay then select is enough for this interview. Reserve happens at select, which is the first moment we know which slot to lock.

pay then select     common; hold is short (select → drop)
select then pay     safer last-item; hold needs TTL
both                do not, unless they insist on a cart

A cart is several holds and one settle — the booking problem. Stay on one slot.

9. State machine

One machine for the session. Slot qty is not a second copy of Alice’s mood.

                 insert
  IDLE ─────────────────────► ACCEPTING_MONEY
   ▲                          │    ▲
   │                    cancel│    │ insert
   │                          │    │
   │                          ▼    │
   │                     (still ACCEPTING)
   │                          │
   │                          │ select
   │                          │  enough money
   │                          │  reserve ok
   │                          │  can make change
   │                          ▼
   │                    ITEM_SELECTED
   │                          │
   │                          │ start motor
   │                          ▼
   │                      DISPENSING
   │                     /          \
   │           drop ok  /            \  jam / timeout
   │                   ▼              ▼
   │              GIVING_CHANGE     REFUNDING
   │                   │              │
   └───────────────────┴──────────────┘
                      IDLE

IDLE has no escrow and no hold. ACCEPTING_MONEY has escrow only. ITEM_SELECTED has escrow and a reserved slot. DISPENSING is physical; software does not take more money and does not cancel.

Illegal transitions:

IDLE            --select-->           (nothing reserved, no money)
ACCEPTING       --dispense-->         (no item chosen)
DISPENSING      --insert-->           (motor owns the face)
GIVING_CHANGE   --select-->           (session already settling)
RESERVED slot   without a session     (leak)
PAID vault      while still DISPENSING (swallow risk)

Cancel from ACCEPTING_MONEY returns escrow and goes to IDLE. Cancel from ITEM_SELECTED (she hit the cancel pad before the motor started) releases the hold, returns escrow, IDLE. That second cancel is the one interviewers forget. During DISPENSING you wait for the sensor.

Idle timeout is cancel. Same method. Do not invent a fourth money path.

BEGIN / synchronized (session + slot)
  ACCEPTING → ITEM_SELECTED
  slot reserved += 1
END

BEGIN / synchronized
  drop success → commit qty, vault += escrow, pay change
  drop fail    → reserved -= 1, refund escrow
END

If you persist a SALE row and then the coil jams, you have a sale without a bag. If you decrement qty and forget the session, Bob’s face still sees a bag that Alice is about to drop. Illegal: qty < reserved, vault credit without a committed drop.

10. Reserve the last item

The claim must be one operation: this session now owns one unit of A4, or it does not.

Same pattern as the parking-lot CAS and a booking hold. Application if qty > 0 without a compare-and-set is not a proof.

In memory: hold count under the inventory lock

synchronized (inventoryLock):
  slot = slots.get(slotId)
  available = slot.qty - slot.reserved
  if available < 1: throw SOLD_OUT
  slot.reserved += 1
  session.reservedSlot = slot

available is what the lamp should show. reserved is a unit that must not be sold again until commit or release.

commit(slot):   qty -= 1; reserved -= 1     // drop succeeded
release(slot):  reserved -= 1               // jam, cancel, cannot-make-change

Decrement-on-reserve plus restock-on-fail is the same invariant if you never display raw qty. I prefer an explicit reserved field so a crash dump still explains why the lamp said zero with one bag behind the glass (Alice is mid-vend).

Two faces, last bag

        Alice (face A)              Inventory                 Bob (face B)
              |                          |                          |
              |---- select(A4) --------->|                          |
              |                          |<---- select(A4) ---------|
              |                          |                          |
              |              A4 qty=1 reserved=0                    |
              |                          |                          |
              |     reserved 0→1, win    |                          |
              |                          |                          |
              |                          |   available = 0          |
              |                          |   SOLD_OUT               |
              |<-- ITEM_SELECTED --------|                          |
              |                          |-------- 409 SOLD_OUT --->|

Both presses are in flight. The first hold writes reserved = 1. The second still asks for available >= 1 and loses. Bob’s escrow is untouched; he can pick B2 or cancel.

Double-press on one face is simpler: the session is already ITEM_SELECTED or DISPENSING. The second select does not call reserve again.

per-slot lock      two different slots vend in parallel
cabinet lock       simple; enough for 60 slots
SQL CAS            UPDATE slots SET reserved = reserved+1
                   WHERE id = A4 AND qty - reserved >= 1

I would start with one inventory lock. I would not open a distributed lock “because two faces.” One cabinet is one writer set.

Zero rows on the SQL form means you lost. Retry is the next slot the customer presses, not a loop over the cabinet.

11. Change: greedy, then refuse

Alice is owed ₹3. That is a constraint, not a display detail. If we cannot produce ₹3, we must not start the motor.

Denominations are ₹1, ₹2, ₹5, ₹10, ₹20. That set is canonical: greedy — largest first — is optimal. Say that. If they give you ₹1, ₹3, ₹4, greedy can fail and you need DP. Do not pretend every coin system is greedy.

canMake(amount, hopper):
  need = amount
  for d in [20, 10, 5, 2, 1]:
    take = min(need / d, hopper[d])
    need -= take * d
  return need == 0

make(amount, hopper):
  if not canMake(amount): return empty
  // same loop, decrement hopper[d]

canMake is a dry run. make mutates the hopper only after the drop succeeds — or, if you pay change after commit in one critical section, after commit and vault credit. Do not empty a tube speculatively during ITEM_SELECTED unless you can put the coins back on jam.

Cannot make change

Hopper is four ₹10 notes. She inserted ₹10 + ₹5 for ₹12 chips.

owed = 15 - 12 = 3
canMake(3) = false
do not reserve, or reserve-then-release in the same lock
refund escrow [10, 5]
light EXACT CHANGE
session → IDLE

I abort and refund, as stated. Keeping her ₹15 in ACCEPTING_MONEY and asking for exact is friendlier and is a product line — say it, then pick refund so select is atomic: either we will vend with change, or she has her cash back.

The exact-change lamp is derived: on if canMake(p - d) fails for common overpay amounts, or if tubes for ₹1/₹2 are empty. The lamp is not a second coin box. select still calls canMake. Alice and Bob both seeing the lamp off does not skip the check.

exact only, no hopper     simplest machine; select requires balance == price
give change when possible this design
recycle notes into tubes  after sale, escrow top-ups short tubes, rest to vault

Recycle is how real hoppers stay alive. Mention it. Model v1 as escrow → vault, change from hopper. Mixing her ₹10 into a ₹10 tube before the drop is how jam refunds become “I no longer have your note.”

Escrow vs vault

insert     coin goes to escrow, not vault
cancel     return escrow (same pieces if the acceptor can reverse; else hopper)
jam        return escrow; hopper untouched
success    escrow → vault (or recycle), hopper pays change

If the note acceptor cannot reverse a ₹10, refund is equivalent value from the hopper. Then canMake must cover the full refund, not only the change, before you accept a non-reversible note. That is the Senior cash-path. If they do not ask, keep reversible escrow.

Never use a float. 12.0 rupees will lie. Integer rupees here; paise if they add ₹12.50.

12. Jam: refund, do not keep the sale

The opening story is a failed physical transition, not a new money API.

DISPENSING
  start coil
  wait up to T ms for drop sensor
  sensor: bag crossed the IR beam → success
  timeout / motor stall / sensor dark → JAM

On jam:

synchronized:
  inventory.release(A4)     // reserved 1 → 0; qty still 1
  coinBox.refund(escrow)    // [10, 5] leave the machine
  session → IDLE
  slot.status may become JAMMED if the spiral is stuck

qty never went to zero, so the next customer can try — unless you mark JAMMED and disable A4 until an attendant clears the coil. I would disable after a jam. Selling “the bag that is hanging on the spiral” is a second double-vend.

Do not:

qty -= 1
vault += 15
// motor jams
// now invent a goodwill refund from the hopper

That is decrement-first. Hopper may not have ₹15. Alice’s notes are already vault. You have a support ticket, not a state machine.

Restock-plus-refund is the same as release if you decremented early: qty += 1 and refund. Prefer never decrementing until the sensor. The hold already stopped Bob.

        Alice                      Machine
          |  select A4, reserved
          |  DISPENSING
          |  timeout
          |  release A4, refund [10, 5]
          |  A4 JAMMED or still qty=1

A bag that drops late, after you refunded, is a mechanical leak. Attendant inventory counts exist for that. Do not add a second select path to “catch” the late bag.

13. Class sketch

Ideas, not a framework dump. Faces share one VendingMachine.

Money         amount: int            // rupees
Coin          denomination: int
Product       id, name, price: Money
Slot          id, product, qty, reserved, status
Session       id, face, state, escrow: Coin[], reservedSlot?

ChangeMaker
  canMake(amount, hopper) -> bool
  make(amount, hopper) -> Coin[] | empty

CoinBox
  insertToEscrow(coin)
  refundEscrow() -> Coin[]
  commitSale(price) -> Coin[]        // vault += escrow, return make(change)
  hopperSnapshot() -> counts

Inventory
  tryReserve(slotId) -> Slot | empty // atomic; available >= 1
  commit(slotId)                     // qty--, reserved--
  release(slotId)                    // reserved--
  markJammed(slotId)

VendingMachine
  insert(face, coin) -> SessionView
  select(face, slotId) -> VendResult
  cancel(face) -> Refund

VendingMachine.select in one breath:

select(face, slotId):
  session = sessions.require(face)
  if session.state != ACCEPTING_MONEY: throw BAD_STATE
  slot = inventory.get(slotId)
  if session.balance < slot.price: throw INSUFFICIENT_FUNDS

  changeDue = session.balance - slot.price
  if not changeMaker.canMake(changeDue, coinBox.hopper):
    refund = coinBox.refundEscrow()
    lamp.on()
    session.reset(IDLE)
    throw CANNOT_MAKE_CHANGE(refund)

  reserved = inventory.tryReserve(slotId)
  if reserved is empty: throw SOLD_OUT

  session.reservedSlot = reserved
  session.state = ITEM_SELECTED
  return dispense(session)

dispense is the only method allowed to call commit or to credit the vault. Keypad handlers do not touch Slot.qty. Tests fake ChangeMaker for denomination tables; they use two threads against a real tryReserve for the last bag.

dispense(session):
  session.state = DISPENSING
  dropped = motor.vend(session.reservedSlot, timeout)
  if dropped:
    change = coinBox.commitSale(session.reservedSlot.price)
    inventory.commit(session.reservedSlot)
    session.reset(IDLE)
    return VENDED(change)
  else:
    inventory.release(session.reservedSlot)
    inventory.markJammed(session.reservedSlot)
    refund = coinBox.refundEscrow()
    session.reset(IDLE)
    return JAMMED(refund)

cancel is refundEscrow plus release if a slot is reserved. It must not run in DISPENSING.

14. Tests that matter

Skip getters. Test the invariant.

  1. Cannot make change. Hopper {10: 4}. Insert ₹10 + ₹5, select ₹12. Refund [10, 5], no reserve, no motor, lamp on. A4 qty unchanged.
  2. Last item race. A4 qty = 1. Two threads select(A4) with enough escrow. One VENDED or DISPENSING, one SOLD_OUT. reserved + sold <= 1.
  3. Cancel refunds. Insert ₹10, ₹5. cancel. Escrow empty, vault unchanged, state IDLE.
  4. Cancel after reserve. If you allow cancel in ITEM_SELECTED, reserved returns to 0 and Bob can then buy A4.
  5. Jam. Motor timeout. Refund full escrow. qty still 1 (or restocked). Vault unchanged. Slot JAMMED is not sold again.
  6. Double-press / insufficient / sold-out. Second select does not reserve twice. ₹10 for ₹12 keeps escrow and takes no hold. qty - reserved = 0 rejects and I keep escrow so she can pick another slot.
  7. Happy change. ₹15 − ₹12 = ₹3 → [2, 1] from a stocked hopper. Idle timeout is cancel.

Tests (1), (2), (3), and (5) are the offer. If they only have time for two, run cannot-make-change and two threads on one bag.

15. Extensions

Stay on objects. Do not add a message bus.

Card tap. Authorization holds rupees at the bank; escrow is a payment intent, not coins. Reserve and dispense stay identical. A jam is a void, not a hopper refund. Failure to void is the ATM reversal problem — see below.

Age-restricted. Cigarettes or alcohol: an AgeGate before tryReserve. Fail closed. The hold must not start until the gate passes, or you leak a reserved bag while she hunts for ID.

Exact-change only hardware. ChangeMaker requires changeDue == 0. Multi-item cart is several reserves and one commitSale — timeout every hold. Snapshot price onto the session at reserve so a remote planogram poke cannot change a live vend.

16. Final architecture / object diagram

One cabinet. One or two faces. One inventory. One coin box.

     Face A keypad                         Face B keypad
          |                                     |
          |  insert / select / cancel           |
          v                                     v
   +-----------------------------------------------+
   |                 VendingMachine                |
   |  sessions[A], sessions[B]                     |
   +-------------+-----------------+---------------+
                 |                 |
        +--------v--+        +-----v------+
        | Inventory |        |  CoinBox   |
        | tryReserve|        |  escrow    |
        | commit    |        |  hopper    |
        | release   |        |  vault     |
        +-----+-----+        +-----+------+
              |                    |
              v                    v
         Slot A4               ChangeMaker
         qty, reserved         canMake / make
              |
              v
         Motor + drop sensor
insert   IDLE|ACCEPTING → escrow += coin
select   canMake? tryReserve → DISPENSING → commit+change | release+refund
cancel   if not DISPENSING: release?, refund escrow → IDLE

Kafka, Redis, and a region pair are not in this picture. If those words appear, you left LLD.

Source of truth    session (escrow, state, reservedSlot) + slot qty/reserved
Derived            sold-out lamps, exact-change lamp, display balance
Ephemeral          motor spin, IR beam, keypad debounce
Audit              committed sales after a successful drop

17. Contrast with an ATM

Both machines take a request, move cash, and can jam. The source of truth for the customer’s money is different.

Vending                         ATM
local CoinBox is the money      bank ledger is the money
hopper is change for this sale  cassette is notes you may dispense
product inventory is the stock  cassette is also stock (of cash)
escrow until drop               authorize / debit, then dispense
jam → refund escrow             jam → reverse the debit
no identity                     card + PIN; session is a bank session

A vending vault is the operator’s till. If we credit it before the bag drops, we stole from Alice. An ATM cassette is the bank’s till. If we debit Alice and then jam, we stole from Alice unless we reverse. Same physical failure, different ledger.

Do not design the vending machine as “a tiny ATM.” There is no account to debit. The shared lesson is: do not commit the ledger (vault or account) until the dispenser succeeds, or you must have a reversal that cannot itself swallow cash. Full ATM walkthrough: Design an ATM. Parking-lot sibling: last stall is last bag — Design a Parking Lot.

18. Interview-ready summary

How to walk through in 10–15 minutes

0–2 min. Alice ₹15, ₹12 chips, jam. Or two faces, last bag. LLD, not a fridge fleet.
2–4 min. Coins+notes, one item, pay then select, change, cancel.
4–6 min. Entities: Session, Slot, CoinBox (escrow / hopper / vault).
6–9 min. Naive if money >= price, both bugs, then the state machine.
9–12 min. tryReserve, greedy canMake, jam = release + refund.
12–15 min. Tests: change, race, cancel, jam. Card and age-gate as the same reserve. ATM = bank vs till.

Key decisions to remember

  1. This is a session and a hold, not a connected-cooler product.
  2. Pay then select, one item, and stay on that story.
  3. Escrow is hers until the drop sensor fires.
  4. Last unit is reserved += 1 where qty - reserved >= 1, same cabinet lock or SQL CAS.
  5. Decrement (commit) only after a successful drop; jam releases and refunds.
  6. Greedy change on canonical INR; if canMake fails, abort and refund, lamp on.
  7. Cancel from ACCEPTING (and before the motor) refunds; ignore cancel while dispensing.
  8. Exact-change and sold-out lamps are derived. select does not trust them.
  9. Two faces share Inventory. Two sessions, one qty.
  10. Card tap replaces escrow with a void; the hold does not change. Fleet scale is more cabinets, not a log.

Likely interviewer follow-up questions

  • Pay first or select first? Why?
  • Two keypads, one bag — prove only one vend.
  • Hopper cannot make ₹3. What do you return?
  • The coil jams after you took ₹15. Walk the rollback.
  • Can she cancel after select but before the motor?
  • Why not decrement qty on the button press? What if the acceptor cannot reverse her ₹10?
  • How does this differ from an ATM dispense jam?
  • Card tap? Age check? Exact-change-only hardware?

Senior-level points that differentiate the answer

  • Name swallowed cash and double-vend before drawing classes.
  • Split escrow, hopper, and vault. Mixing them is how refunds fail.
  • Tie tryReserve to parking-lot occupy and booking holds — same WHERE trick.
  • Call canMake before the motor, not after vault += 15.
  • Cancel during DISPENSING is not a third money path; the sensor is.
  • Snapshot price at reserve; a remote price poke must not change a live session.
  • Mark a jammed slot unsalable. The hanging bag is not stock.

A 1–2 minute verbal answer

A vending machine is a session, a slot inventory, and a coin box. I take pay-then-select: inserts go to escrow, not the vault. On select I check funds, check that the hopper can make change, then atomically reserve one unit (qty - reserved >= 1). That stops two faces from selling the last bag. The motor runs only after the hold. A drop sensor commits: qty goes down, escrow moves to the vault, hopper pays change. A jam or timeout releases the hold and refunds escrow — we never decrement permanently for a bag that did not fall. Cancel from accepting money is that same refund. If we cannot make change, I abort and refund and light the exact-change lamp. I would test cannot-make-change, two threads on qty 1, cancel, and jam. Card tap swaps coins for a void; the reserve is the same. This stays in one process. An ATM is the same jam problem against a bank ledger, not a local till.

For HLD framing around this, see the System Design Interview Complete Guide and the questions hub. The parking-lot sibling is Design a Parking Lot. The cash-dispense sibling is Design an ATM.