Everyone treats ādesign a vending machineā as a warm-up, and I used to as well, right up until an interviewer let me finish my clean little enum-and-switch version and then asked āokay, what does inserting a coin do while itās dispensing?ā and my switch statement grew a fourth case in every method and I watched my own code turn into the thing Iād have failed a candidate for. Thatās the whole trap. The vending machine isnāt testing whether you can model a product and a coin. Itās testing whether you can model a lifecycle where the same button means different things at different times, and keep the rules for each moment in one place instead of smeared across the codebase.
The tell is right there in the requirements if you listen for it: insert a coin, select a product, dispense. The exact same three verbs, but āinsert a coinā while idle starts a session, while it already has your money it accumulates, and while itās dispensing it should be refused. Different work, same call. Thatās the State patternās home turf, and the interviewer knows it, so the win here is naming that early and building around it instead of discovering it halfway through.
The problem
Lock the scope out loud before you write anything. Four operations, thatās the machine:
- insertCoin(coin): feed money in, one coin at a time.
- selectProduct(slotId): pick a row; if youāve paid enough and itās in stock, this commits the sale.
- dispense(): drop the product and return change.
- cancel(): bail out and get your money back.
Explicitly out of scope, and say so: card and UPI payments (one payment path keeps the state machine legible), the physical coin-return mechanics, restocking workflows, multi-item carts, and any persistence. In-memory, single machine, and Iāll assume concurrency matters because at SDE-2 and up it always does.
Entities and invariants
Nouns first, straight to classes. VendingMachine is the context that holds the current state and the shared money and stock. Product is a plain value: name, price in cents. A Slot is one row: it owns a Product and a count, and it gets real behavior, not just getters, isAvailable(), dispenseOne() which decrements or throws. Inventory wraps the map of slotId to Slot. Coin is an enum with a cent value (PENNY(1), NICKEL(5), DIME(10), QUARTER(25)). Ownership: the machine owns one Inventory, the inventory owns many Slots, each slot owns exactly one Product type and a count.
classDiagram
class VendingMachine {
-state: MachineState
-inventory: Inventory
-balanceCents: int
+insertCoin(Coin)
+selectProduct(String)
+dispense() Product
+cancel() int
}
class Inventory {
-slots: Map~String, Slot~
+slot(String) Slot
}
class Slot {
-product: Product
-count: int
+isAvailable() boolean
+dispenseOne() Product
}
class Product { -name: String; -priceCents: int }
class Coin {
<<enum>>
PENNY
NICKEL
DIME
QUARTER
}
VendingMachine o-- Inventory
VendingMachine o-- MachineState
Inventory o-- Slot
Slot o-- Product
The invariants are the actual content of this problem, so write them as a comment block before you code and let them drive both your guards and your locks:
- Dispense only if
balanceCents >= product.priceAND the slotāscount > 0. Both, always, no exceptions. This is the sale precondition. - Change returned =
balanceCents - product.price, and the balance resets to zero after. Money in must equal product value out plus change out. Nothing leaks. - Money and stock never move independently. You never decrement stock without collecting payment, and you never keep payment without either dispensing or refunding. A sale is all-or-nothing: stock down, balance consumed, change out, together or not at all.
That third one is the whole reason the concurrency section later exists. Hold onto it.
The variation axis
Hereās the judgment call to make out loud, because itās the one that scores. The thing that varies in this problem is not a swappable algorithm, thereās no pricing strategy or matching rule to plug in. The variation lives in the machineās states. The same four calls behave differently depending on whether the machine is idle, holding money, dispensing, or sold out. Thatās the State Variation Playbookās strongest trigger, per-state behavior differences, not just per-state legality, and itās exactly the case that earns full state classes rather than a transition table.
Say why State beats the obvious enum-plus-switch. The switch version puts one switch (state) inside insertCoin, another inside selectProduct, another inside dispense, another inside cancel. Add a state and you reopen all four methods; forget one and youāve got a silent bug in whatever case you missed. The rules for āwhat does the machine do while dispensingā are scattered across four different methods instead of sitting together. State classes flip that: one class per state, and each class holds everything that machine-moment knows how to do. Add a card-payment state later and itās one new file, the existing states never open.
Model it as an interface where every state answers the same four events:
interface MachineState {
MachineState insertCoin(VendingMachine ctx, Coin coin);
MachineState selectProduct(VendingMachine ctx, String slotId);
MachineState dispense(VendingMachine ctx);
MachineState cancel(VendingMachine ctx);
}
The context owns the field, states return the next state, and the context assigns it. States stay stateless so they can be shared singletons:
final class IdleState implements MachineState {
static final IdleState INSTANCE = new IdleState();
public MachineState insertCoin(VendingMachine ctx, Coin coin) {
ctx.addBalance(coin.cents());
return HasMoneyState.INSTANCE; // money in -> move to HAS_MONEY
}
public MachineState selectProduct(VendingMachine ctx, String slotId) {
throw new IllegalStateException("insert money first");
}
public MachineState dispense(VendingMachine ctx) {
throw new IllegalStateException("nothing selected");
}
public MachineState cancel(VendingMachine ctx) {
return this; // nothing to refund, no-op
}
}
final class HasMoneyState implements MachineState {
static final HasMoneyState INSTANCE = new HasMoneyState();
public MachineState insertCoin(VendingMachine ctx, Coin coin) {
ctx.addBalance(coin.cents());
return this; // accumulate, stay put
}
public MachineState selectProduct(VendingMachine ctx, String slotId) {
Slot slot = ctx.inventory().slot(slotId);
if (!slot.isAvailable()) return OutOfStockState.INSTANCE;
if (ctx.balanceCents() < slot.product().priceCents())
return this; // not enough yet, keep waiting
ctx.selectSlot(slotId);
return DispensingState.INSTANCE;
}
public MachineState dispense(VendingMachine ctx) {
throw new IllegalStateException("select a product first");
}
public MachineState cancel(VendingMachine ctx) {
ctx.refundBalance(); // hand the coins back
return IdleState.INSTANCE;
}
}
DispensingState.dispense() is where invariant 3 gets enforced in one place: decrement the slot, compute balance - price as change, return both, reset balance, go back to IdleState. OutOfStockState refuses selection and only leaves when restocked. The transition table, which is worth drawing on the whiteboard so the interviewer sees the shape:
| State | insertCoin | selectProduct | dispense | cancel |
|---|---|---|---|---|
| Idle | ā HasMoney | reject | reject | no-op |
| HasMoney | accumulate, stay | ā Dispensing (if paid+stock) / OutOfStock (if empty) / stay | reject | refund ā Idle |
| Dispensing | reject | reject | drop + change ā Idle | reject |
| OutOfStock | ā HasMoney | reject | reject | refund ā Idle |
The public API on VendingMachine is the same four methods no matter which tier youād pick; each just delegates this.state = state.insertCoin(this, coin) and friends. That uniform delegation is the payoff.
Making it thread-safe
Now the honest part, the reason invariant 3 was flagged. Picture two people at one machine, or two threads in the interviewerās test harness, both with enough balance, both selecting the last item in a slot at the same time. Thread A reads count == 1, thread B reads count == 1, both pass the isAvailable() check, both proceed to dispense, and now youāve dropped two products from a slot that had one. Thatās a classic check-then-act race, the read of the stock and the write that consumes it are two separate steps, and anything can slip between them.
The framing that matters: the entire select ā dispense path is one transaction over the machineās shared money and stock. Reading the balance, checking stock, decrementing the slot, consuming the balance, computing change, these must be atomic together or invariant 3 breaks. So the atomic boundary isnāt a single map key, itās the whole state-transition-plus-mutation on this machineās shared fields.
The correct first move, and Iād say it out loud, is a single lock per machine: make the state-transition entry point synchronized (or guard it with one ReentrantLock) so a full insert-select-dispense sequence canāt interleave with another. āThis serializes all interactions with one machine, which is correct, and one physical machine only serves one person at a time anyway, so serializing per machine matches reality.ā That last bit is the point: unlike a parking lot where per-machine locking would kill throughput, a vending machine genuinely is a serial device. Coarse is right here, not lazy.
If the interviewer pushes on throughput across a fleet, the answer is a lock per machine instance, not one global lock, so a thousand machines run in parallel and only same-machine operations contend. If they push further, per-slot locking lets two people buy from different rows of the same machine at once, but now youāre locking the slot for the stock decrement and the machine for the balance, two locks, acquired in a fixed order to avoid deadlock, and Iād only reach for it if asked because it complicates the all-or-nothing guarantee for marginal gain on a device thatās physically serial.
One more discipline from the playbook: keep any side effects, logging the sale, notifying a restock listener, outside the lock and after the state write commits. A listener that calls back into the machine while youāre holding its lock is a deadlock you built yourself.
The takeaway
The vending machine looks small and it is, but itās the cleanest problem in the bank for showing you understand that a lifecycle with per-state rules wants State classes, not a switch you keep reopening. Model the states, put the four events on an interface, let each state own its own behavior, and guard the whole transaction with one lock because the device is serial by nature.
And the extensibility pitch writes itself, which is exactly why interviewers like this one. To add card payment, you add an AwaitingCardState and the existing states never change. To add a new product row, you drop a Slot into the inventory map, zero code. Open for extension, closed for modification, falling straight out of putting the variation where it actually lives.