Design a Vending Machine, Low Level Design (LLD) Interview
An object-oriented Vending Machine design where the machine moves through Idle, HasMoney, Dispensing and OutOfStock states using the State pattern, with an inventory of product slots and a change-making module for coins and notes.
Asked at: Asked in machine coding and LLD rounds at companies like Amazon, Walmart, Flipkart, Swiggy, Uber and PhonePe. It is one of the three or four "default" warm-up problems interviewers pick because it maps so cleanly to the State pattern.
Why this is asked
The interviewer wants to see if you reach for the State pattern instead of a giant nested if-else over a status flag. A vending machine has a small, well-defined set of states and the allowed action in each state is different (you can insert money only when idle or collecting, you can select only after paying). That makes it the textbook problem for finite state behavior, while still forcing you to handle real money math (change making) and inventory correctly. It also tests whether you separate concerns: state machine vs inventory vs cash register are three distinct responsibilities, and weak candidates cram them into one class.
Requirements
Functional
- Display available products with price and remaining quantity per slot
- Accept money in valid denominations (coins and notes), one insertion at a time, accumulating a running balance
- Let the user select a product code; reject selection if the slot is empty or balance is insufficient
- Dispense the product when balance is sufficient and the slot has stock
- Return correct change after dispensing, using available denominations in the cash register
- Allow the user to cancel the transaction at any point before dispensing and get a full refund
- Support a refill / restock operation and a cash-collection operation for the operator/admin
- Track that the machine enters an OutOfStock state when every slot is empty
Constraints & non-functional
- Never dispense without sufficient payment, and never dispense from an empty slot
- Never hand out more money than the user is owed; if exact change cannot be made, refuse the sale and refund
- Money math must be exact: use integer minor units (paise/cents), never floating point
- Each physical slot holds exactly one product type with a fixed price and a capacity
- Operations must be safe if two threads touch the machine, since a real machine has concurrent button presses and a maintenance port
- The machine should be in exactly one state at any instant
Core classes & entities
VendingMachine
The context object. Holds the current state, the inventory, the cash register and the running balance for the active transaction. Delegates every user action to the current state and exposes setState so states can transition the machine.
attrs: currentState: VendingMachineState, idleState, hasMoneyState, dispensingState, outOfStockState: VendingMachineState, inventory: Inventory, cashRegister: CashRegister, currentBalance: long (minor units), selectedSlot: ItemShelf
methods: insertMoney(Coin coin), selectProduct(String slotCode), dispense(), cancel(), refund(): long, setState(VendingMachineState state), addBalance(long amount), resetBalance()
VendingMachineState
Interface for the four states. Each concrete state decides what insertMoney, selectProduct, dispense and cancel mean in that state, and triggers the legal transition. Illegal actions throw or are no-ops with a message.
methods: insertMoney(VendingMachine m, Coin coin), selectProduct(VendingMachine m, String slotCode), dispense(VendingMachine m), cancel(VendingMachine m)
IdleState
Default state. Accepts money (moves to HasMoneyState). Rejects select and dispense because no money is in.
methods: insertMoney(...) transitions to HasMoneyState, selectProduct(...) rejects, dispense(...) rejects
HasMoneyState
Money is being accumulated. Accepts more money. On selectProduct it validates stock and balance, and if sufficient moves to DispensingState. On cancel it refunds and returns to Idle.
methods: insertMoney(...) adds to balance, selectProduct(...) validates then transitions to DispensingState, cancel(...) refunds, transitions to IdleState
DispensingState
Terminal step of a sale. Drops the product, computes and returns change, decrements inventory, then returns the machine to Idle (or OutOfStock if the machine is now empty).
methods: dispense(...) decrements stock, makes change, transitions to Idle/OutOfStock, insertMoney/selectProduct/cancel rejected mid-dispense
OutOfStockState
Reached when every slot is empty. Rejects money and selection. Only a restock by the operator moves it back to Idle.
methods: all user actions rejected with out-of-stock message
Inventory
Owns the map of slot code to ItemShelf. Knows how to look up a slot, check stock, decrement on sale, restock, and report whether the whole machine is empty.
attrs: shelves: Map<String, ItemShelf>
methods: getShelf(String code): ItemShelf, hasStock(String code): boolean, deduct(String code), restock(String code, int qty), isEmpty(): boolean, addSlot(ItemShelf shelf)
ItemShelf
A single physical slot. Holds one Product type, its price, and the current count. Encapsulates the slot capacity invariant.
attrs: code: String, product: Product, price: long, quantity: int, capacity: int
methods: isAvailable(): boolean, decrement(), refill(int qty)
Product
Immutable value object describing what is sold (name, id). Price lives on the shelf because the same product can be priced differently or sit in multiple slots.
attrs: id: String, name: String
methods: getId(), getName()
CashRegister
Holds the count of each denomination the machine physically owns. Accepts inserted money and runs the change-making algorithm. The single source of truth for whether exact change is possible.
attrs: denominations: Map<Coin, Integer>
methods: addCoin(Coin coin), canMakeChange(long amount): boolean, dispenseChange(long amount): Map<Coin,Integer>, totalCash(): long, collectCash(): long
Coin
Enum of accepted denominations, each carrying its value in minor units. Modeling money as an enum prevents invalid denominations from ever entering the machine.
attrs: value: long
methods: getValue(): long
Relationships
- VendingMachine → association → VendingMachineState. Context holds a reference to its current state and to one cached instance of each concrete state; it delegates all actions to currentState.
- IdleState → inheritance → VendingMachineState. IdleState, HasMoneyState, DispensingState and OutOfStockState all implement the VendingMachineState interface.
- VendingMachine → composition → Inventory. The machine owns its inventory; the inventory does not exist outside a machine.
- VendingMachine → composition → CashRegister. The machine owns its cash register and the physical cash inside it.
- Inventory → composition → ItemShelf. Inventory owns a map of shelves keyed by slot code; destroying the inventory destroys the shelves.
- ItemShelf → association → Product. A shelf references the product it stocks; the same Product value object can be referenced by multiple shelves.
- CashRegister → association → Coin. The register keeps a count per Coin enum value.
Design patterns used
State in VendingMachine (context) + IdleState, HasMoneyState, DispensingState, OutOfStockState
The legal action set changes per state. Encapsulating each state as its own class removes the giant switch over a status flag, makes transitions explicit (each state knows its successor), and makes adding a state like MaintenanceState a localized change instead of editing every method. This is the heart of the problem and the thing the interviewer is looking for.
Strategy in Change-making inside CashRegister (greedy vs dynamic-programming exact-change strategy)
How you make change is an interchangeable policy. A naive greedy works for canonical denomination sets but fails for arbitrary ones; isolating it behind a ChangeStrategy lets you swap a DP-based exact-change algorithm without touching the register or the states.
Singleton in VendingMachine instance (one physical machine) and optionally the State instances
A physical machine maps to exactly one process object, so a single shared instance is natural. Each concrete state is stateless, so one cached instance per state is shared by the context rather than allocating a new state object on every transition (flyweight-style reuse).
Factory in Optional VendingMachineFactory / state lookup
Centralizes construction of a fully wired machine (states, inventory, register) so client code does not hand-assemble the object graph, and keeps state instantiation in one place.
Observer in Optional low-stock and out-of-cash notifications to an operator dashboard
When a shelf crosses a low-stock threshold or the register cannot make change, observers (display, telemetry, restock alert) react without the inventory or register knowing who is listening.
Enums
Key API / methods
void insertMoney(Coin coin)User feeds one coin or note. Delegated to the current state; valid in Idle (transitions to HasMoney) and HasMoney (accumulates), rejected in Dispensing and OutOfStock.
void selectProduct(String slotCode)User presses a slot code. The current state validates that the slot exists, has stock, and the running balance covers the price, then transitions to Dispensing. Otherwise it surfaces INVALID_SLOT, OUT_OF_STOCK or INSUFFICIENT_FUNDS.
Product dispense()Completes the sale: decrements the shelf, computes change, and returns the product. Internally also returns change to the tray. Returns the machine to Idle, or OutOfStock if the machine is now empty.
long cancel()Aborts the active transaction and refunds the full inserted balance. Legal only before dispensing; returns the refunded amount.
boolean canMakeChange(long amount)On CashRegister. Returns whether the register can assemble exactly this amount from the denominations it physically holds. Called before committing a sale so the machine never dispenses without being able to give change.
Map<Coin,Integer> dispenseChange(long amount)On CashRegister. Removes and returns the denomination breakdown for the given change amount, mutating the register counts. Throws if change is impossible (guarded by canMakeChange).
void restock(String slotCode, int qty)Operator API. Refills a shelf up to capacity and, if the machine was OutOfStock, transitions it back to Idle.
Code skeleton
// ---- Enums ----
enum Coin {
COIN_1(1), COIN_2(2), COIN_5(5), COIN_10(10),
NOTE_20(20), NOTE_50(50), NOTE_100(100);
final long value;
Coin(long v) { this.value = v; }
public long getValue() { return value; }
}
// ---- State interface ----
interface VendingMachineState {
void insertMoney(VendingMachine m, Coin coin);
void selectProduct(VendingMachine m, String slotCode);
void dispense(VendingMachine m);
void cancel(VendingMachine m);
}
// ---- Context ----
class VendingMachine {
private VendingMachineState currentState;
final VendingMachineState idle = new IdleState();
final VendingMachineState hasMoney = new HasMoneyState();
final VendingMachineState dispensing= new DispensingState();
final VendingMachineState outOfStock= new OutOfStockState();
private final Inventory inventory;
private final CashRegister register;
private long balance = 0; // minor units
private String selectedSlot;
VendingMachine(Inventory inv, CashRegister reg) {
this.inventory = inv; this.register = reg; this.currentState = idle;
}
// delegate every action to the current state
public synchronized void insertMoney(Coin c) { currentState.insertMoney(this, c); }
public synchronized void selectProduct(String code) { currentState.selectProduct(this, code); }
public synchronized Product dispense() { currentState.dispense(this); return null; }
public synchronized long cancel() { long r = balance; currentState.cancel(this); return r; }
void setState(VendingMachineState s) { this.currentState = s; }
void addBalance(long amt) { balance += amt; }
void resetBalance() { balance = 0; }
long getBalance() { return balance; }
void setSelectedSlot(String s) { selectedSlot = s; }
String getSelectedSlot() { return selectedSlot; }
Inventory inventory() { return inventory; }
CashRegister register() { return register; }
}
// ---- States ----
class IdleState implements VendingMachineState {
public void insertMoney(VendingMachine m, Coin c) {
m.register().addCoin(c);
m.addBalance(c.getValue());
m.setState(m.hasMoney);
}
public void selectProduct(VendingMachine m, String s) { throw new IllegalStateException("Insert money first"); }
public void dispense(VendingMachine m) { throw new IllegalStateException("Nothing selected"); }
public void cancel(VendingMachine m) { /* no-op */ }
}
class HasMoneyState implements VendingMachineState {
public void insertMoney(VendingMachine m, Coin c) {
m.register().addCoin(c); m.addBalance(c.getValue());
}
public void selectProduct(VendingMachine m, String code) {
ItemShelf shelf = m.inventory().getShelf(code);
if (shelf == null) throw new IllegalArgumentException("INVALID_SLOT");
if (!shelf.isAvailable()) throw new IllegalStateException("OUT_OF_STOCK");
if (m.getBalance() < shelf.getPrice()) throw new IllegalStateException("INSUFFICIENT_FUNDS");
long change = m.getBalance() - shelf.getPrice();
if (change > 0 && !m.register().canMakeChange(change)) {
// cannot complete fairly -> refund and bail
m.resetBalance(); m.setState(m.idle);
throw new IllegalStateException("CANNOT_MAKE_CHANGE");
}
m.setSelectedSlot(code);
m.setState(m.dispensing);
m.dispense(); // drive the terminal step
}
public void dispense(VendingMachine m) { throw new IllegalStateException("Select a product"); }
public void cancel(VendingMachine m) {
m.register().refund(m.getBalance()); // hand cash back
m.resetBalance(); m.setState(m.idle);
}
}
class DispensingState implements VendingMachineState {
public void insertMoney(VendingMachine m, Coin c) { throw new IllegalStateException("Dispensing in progress"); }
public void selectProduct(VendingMachine m, String s) { throw new IllegalStateException("Dispensing in progress"); }
public void cancel(VendingMachine m) { throw new IllegalStateException("Too late to cancel"); }
public void dispense(VendingMachine m) {
ItemShelf shelf = m.inventory().getShelf(m.getSelectedSlot());
long change = m.getBalance() - shelf.getPrice();
shelf.decrement(); // atomic under machine lock
if (change > 0) m.register().dispenseChange(change); // give change
m.resetBalance(); m.setSelectedSlot(null);
m.setState(m.inventory().isEmpty() ? m.outOfStock : m.idle);
}
}
class OutOfStockState implements VendingMachineState {
public void insertMoney(VendingMachine m, Coin c) { throw new IllegalStateException("Machine empty"); }
public void selectProduct(VendingMachine m, String s) { throw new IllegalStateException("Machine empty"); }
public void dispense(VendingMachine m) { throw new IllegalStateException("Machine empty"); }
public void cancel(VendingMachine m) { /* no-op */ }
}
// ---- Inventory + shelf ----
class ItemShelf {
final String code; final Product product; final long price;
int quantity; final int capacity;
ItemShelf(String code, Product p, long price, int qty, int cap) {
this.code = code; this.product = p; this.price = price;
this.quantity = qty; this.capacity = cap;
}
boolean isAvailable() { return quantity > 0; }
long getPrice() { return price; }
void decrement() { if (quantity == 0) throw new IllegalStateException("empty"); quantity--; }
void refill(int qty) { quantity = Math.min(capacity, quantity + qty); }
}
class Inventory {
private final Map<String, ItemShelf> shelves = new HashMap<>();
void addSlot(ItemShelf s) { shelves.put(s.code, s); }
ItemShelf getShelf(String code) { return shelves.get(code); }
boolean isEmpty() { return shelves.values().stream().noneMatch(ItemShelf::isAvailable); }
}
// ---- Cash register with pluggable change strategy ----
interface ChangeStrategy { Map<Coin,Integer> make(long amount, Map<Coin,Integer> available); }
class CashRegister {
private final Map<Coin,Integer> bank = new EnumMap<>(Coin.class);
private final ChangeStrategy strategy;
CashRegister(ChangeStrategy s) { this.strategy = s; }
void addCoin(Coin c) { bank.merge(c, 1, Integer::sum); }
boolean canMakeChange(long amt) { return strategy.make(amt, bank) != null; }
Map<Coin,Integer> dispenseChange(long amt) {
Map<Coin,Integer> out = strategy.make(amt, bank);
if (out == null) throw new IllegalStateException("CANNOT_MAKE_CHANGE");
out.forEach((c,n) -> bank.merge(c, -n, Integer::sum));
return out;
}
void refund(long amt) { /* return inserted coins, simplified */ }
}How it works
Start with the happy path so the object collaboration is clear. A user walks up to a VendingMachine that is in IdleState. They insert a 10 rupee coin. The machine does not handle this itself; it forwards the call to currentState.insertMoney(...). IdleState adds the coin to the CashRegister, bumps the machine balance to 10, and flips the machine into HasMoneyState. The point of the State pattern shows up immediately: "insert money" means something different depending on where you are, so each state owns its own version of the method instead of the machine running a switch over a status int.
The user inserts another 10. Now we are in HasMoneyState, whose insertMoney just keeps accumulating, so balance is 20. They press slot code A1. HasMoneyState.selectProduct does the real validation work. It asks the Inventory for shelf A1. If the slot does not exist it raises INVALID_SLOT, if the shelf has zero quantity it raises OUT_OF_STOCK, and if the balance is below the shelf price it raises INSUFFICIENT_FUNDS. Suppose A1 costs 15. The change owed is 5. Before committing, the state asks the CashRegister whether it can make 5 from the coins it physically holds. This is the subtle correctness rule most candidates miss: you must verify change is possible before you take the sale, otherwise you either short the customer or dispense for free. If change cannot be made, the state refunds the balance, returns to Idle, and surfaces CANNOT_MAKE_CHANGE.
Change is possible, so the state records the selected slot and transitions to DispensingState, then drives the terminal dispense step. DispensingState decrements shelf A1 by one, calls CashRegister.dispenseChange(5) which mutates the denomination counts and returns the breakdown, resets the balance to zero, clears the selection, and then decides the next state. If the whole Inventory is now empty it goes to OutOfStockState, otherwise back to Idle. The user collects the product and 5 rupees of change. Notice the decrement and the change-dispense happen while the machine lock is held, so two simultaneous presses cannot both pass the stock check and double-dispense.
The alternate paths reuse the same machinery. Cancel in HasMoneyState refunds the full balance and returns to Idle. Any user action in DispensingState is rejected because a sale in flight should not be interrupted. OutOfStockState rejects money and selection and only an operator restock pulls it back to Idle. The CashRegister is deliberately a separate object from the state machine and from the inventory, because change-making is its own concern with its own pluggable Strategy. A canonical denomination set works with greedy, but an arbitrary set needs a dynamic-programming exact-change algorithm, and isolating it behind ChangeStrategy means you swap the algorithm without touching a single state class. That separation of three responsibilities, state behavior, inventory, and cash, is what turns this from a toy into an interview-grade design.
Edge cases & gotchas
- User selects a product before inserting any money (Idle rejects selection)
- User inserts more money than the price; machine must return the exact difference as change
- Exact change cannot be made from the denominations in the register; the sale must be refused and the full balance refunded rather than short-changing or over-paying the user
- Selected slot is valid but empty while other slots still have stock (slot-level OUT_OF_STOCK, machine stays usable)
- Last item in the whole machine is sold; machine must transition to OutOfStockState
- User cancels mid-transaction after inserting several coins; full refund and return to Idle
- User presses dispense or cancel while the machine is mid-dispense (Dispensing rejects re-entry)
- Counterfeit or unrecognized denomination; rejected at the door because only Coin enum values are accepted
- Money math drift: storing prices and balances as floating point causes rounding errors, so everything is integer minor units
- Concurrency: two simultaneous presses could both pass the stock check and double-dispense, so the deduct-and-dispense step must be atomic
- Power loss mid-dispense leaves money taken but no product; a real design needs the deduct and the cash commit to be transactional, and an idempotent recovery on reboot
- Refill beyond shelf capacity must be capped at capacity