Design a Vending Machine
Design a coin-operated vending machine with the State pattern, slot-based inventory, an escrow for inserted coins, and a change maker that never sells an item it cannot make change for.
Requirements
The vending machine is the textbook State-pattern question. The interviewer wants to see a clean state machine, correct handling of money (refunds, change, no lost coins) and a model of inventory that a real operator could maintain.
Functional
- Products sit in slots identified by a code (
A1,B2, ...). Each slot holds one product type and a quantity. - The customer inserts coins (nickel, dime, quarter, dollar coin) and the machine shows the balance.
- The customer selects a slot. The machine dispenses the product and returns change if:
- the slot exists and is not empty,
- the balance covers the price, and
- the machine can make exact change from the coins it holds.
- The customer can cancel at any time before a sale and gets back exactly the coins they inserted.
- An operator can take the machine out of service to restock products and refill coins.
Non-functional
- Money must never be lost or created: every coin is in the escrow, the cash box or returned to the customer.
- Adding a new state (e.g. "card payment pending") should not require editing every other state.
- One customer at a time, but the operator console and the customer panel may call in concurrently.
Out of scope (confirm first)
- Card or mobile payment, bill acceptors, telemetry to a central server, temperature control.
Clarify the "cannot make change" case up front. Many candidates dispense the product and then discover there is no change, which means either the customer or the operator loses money.
Core Entities
| Entity | Responsibility | Key fields |
|---|---|---|
Coin (enum) |
Accepted denominations and their value | cents |
Product (record) |
Name and price | name, priceCents |
Slot |
One shelf position holding a product and a count | code, product, quantity |
Inventory |
Looks up slots, restocks, checks if everything is sold out | Map<String, Slot> |
CoinBox |
Coins the machine owns; plans and pays change | EnumMap<Coin, Integer> |
VendingState |
Behaviour of the machine in one mode | interface |
ReadyState, HasMoneyState, OutOfServiceState |
Concrete states | - |
Sale (record) |
Result of a successful purchase | product, change |
VendingMachine |
Context object: holds current state, escrow and balance; delegates every customer action to the state | state, escrow, inventory, coinBox |
The escrow is the most important modelling decision. Real machines hold inserted coins in a separate tray until the sale completes. That makes cancel trivial (return the escrow as-is) and keeps the cash box consistent: coins move into it only when a sale commits.
Class Diagram
classDiagram
class Coin {
<<enumeration>>
NICKEL
DIME
QUARTER
DOLLAR
+int cents
}
class Product {
+String name
+int priceCents
}
class Slot {
-String code
-Product product
-int quantity
+isEmpty() boolean
+takeOne() void
+restock(int) void
}
class Inventory {
-Map slots
+find(String) Optional
+allSoldOut() boolean
}
class CoinBox {
-EnumMap counts
+planChange(int, List) Optional
+deposit(List) void
+withdraw(List) void
}
class VendingState {
<<interface>>
+insertCoin(VendingMachine, Coin) boolean
+select(VendingMachine, String) Optional
+cancel(VendingMachine) List
}
class ReadyState
class HasMoneyState
class OutOfServiceState
class VendingMachine {
-VendingState state
-List escrow
-Inventory inventory
-CoinBox coinBox
+insertCoin(Coin) boolean
+select(String) Optional
+cancel() List
+enterService() void
+exitService() void
}
class Sale {
+Product product
+List change
}
VendingState <|.. ReadyState
VendingState <|.. HasMoneyState
VendingState <|.. OutOfServiceState
VendingMachine --> VendingState
VendingMachine *-- Inventory
VendingMachine *-- CoinBox
Inventory "1" *-- "many" Slot
Slot --> Product
VendingMachine ..> Sale : returns
CoinBox --> Coin
The state machine itself:
flowchart LR Ready -- "insert coin" --> HasMoney HasMoney -- "insert coin" --> HasMoney HasMoney -- "select: short of money, sold out, no change" --> HasMoney HasMoney -- "select: sale ok" --> Ready HasMoney -- "cancel: refund escrow" --> Ready Ready -- "operator: service" --> OutOfService OutOfService -- "operator: resume" --> Ready HasMoney -- "sale empties machine" --> OutOfService
Design Patterns Used
| Pattern | Where | Why it is justified |
|---|---|---|
| State | VendingState + three states |
Every customer action means something different per mode: inserting a coin in OutOfService is rejected, in Ready it starts a session. Each state class answers all actions for one mode, so there are no if (state == ...) chains |
| Facade / context | VendingMachine |
The keypad, coin slot and operator console talk to one object; the current state is an internal detail |
| Value objects | Coin, Product, Sale |
Immutable, comparable and safe to hand back to callers |
| Two-phase commit (in miniature) | planChange then withdraw |
Check that every part of the sale can succeed before changing any state |
State vs. a big switch. A switch on an enum is acceptable for three states. Argue for the pattern by pointing at growth: card payment, age verification for some products, a "dispensing" state that waits for a drop sensor. Each becomes one new class implementing the same interface, and existing states change only where a transition into the new state is added.
Greedy change (always take the largest coin) fails when the machine is low on coins. Owing 30 cents with quarters and three dimes but no nickels, greedy takes a quarter and gets stuck at 5 cents, yet three dimes would work. The change maker below tries the largest coin first and backtracks, which is cheap because there are only four denominations.
Java Implementation
A single Main.java (Java 17+). All money is in integer cents.
import java.util.*; enum Coin { NICKEL(5), DIME(10), QUARTER(25), DOLLAR(100); final int cents; Coin(int cents) { this.cents = cents; } } record Product(String name, int priceCents) {} record Sale(Product product, List<Coin> change) {} class Slot { private final String code; private final Product product; private int quantity; Slot(String code, Product product, int quantity) { this.code = code; this.product = product; this.quantity = quantity; } String getCode() { return code; } Product getProduct() { return product; } int getQuantity() { return quantity; } boolean isEmpty() { return quantity == 0; } void takeOne() { if (quantity == 0) throw new IllegalStateException(code + " is empty"); quantity--; } void restock(int count) { quantity += count; } @Override public String toString() { return code + ":" + product.name() + "x" + quantity; } } class Inventory { private final Map<String, Slot> slots = new LinkedHashMap<>(); void addSlot(String code, Product product, int quantity) { slots.put(code, new Slot(code, product, quantity)); } Optional<Slot> find(String code) { return Optional.ofNullable(slots.get(code)); } boolean allSoldOut() { return slots.values().stream().allMatch(Slot::isEmpty); } void restock(String code, int count) { find(code).orElseThrow(() -> new IllegalArgumentException("No slot " + code)).restock(count); } @Override public String toString() { return slots.values().toString(); } } class CoinBox { private static final Coin[] LARGEST_FIRST = { Coin.DOLLAR, Coin.QUARTER, Coin.DIME, Coin.NICKEL }; private final EnumMap<Coin, Integer> counts = new EnumMap<>(Coin.class); CoinBox() { for (Coin c : Coin.values()) counts.put(c, 0); } void deposit(List<Coin> coins) { coins.forEach(c -> counts.merge(c, 1, Integer::sum)); } void withdraw(List<Coin> coins) { for (Coin c : coins) { if (counts.get(c) == 0) throw new IllegalStateException("No " + c + " left"); counts.merge(c, -1, Integer::sum); } } // Plans change using coins in the box plus the customer's pending coins; mutates nothing. Optional<List<Coin>> planChange(int amount, List<Coin> pending) { EnumMap<Coin, Integer> available = new EnumMap<>(counts); pending.forEach(c -> available.merge(c, 1, Integer::sum)); List<Coin> out = new ArrayList<>(); return search(0, amount, available, out) ? Optional.of(out) : Optional.empty(); } private boolean search(int i, int remaining, Map<Coin, Integer> available, List<Coin> out) { if (remaining == 0) return true; if (i == LARGEST_FIRST.length) return false; Coin coin = LARGEST_FIRST[i]; int max = Math.min(remaining / coin.cents, available.get(coin)); for (int k = max; k >= 0; k--) { // most of this coin first, then back off for (int j = 0; j < k; j++) out.add(coin); if (search(i + 1, remaining - k * coin.cents, available, out)) return true; for (int j = 0; j < k; j++) out.remove(out.size() - 1); } return false; } @Override public String toString() { return counts.toString(); } } interface VendingState { String label(); boolean insertCoin(VendingMachine vm, Coin coin); Optional<Sale> select(VendingMachine vm, String code); List<Coin> cancel(VendingMachine vm); } class ReadyState implements VendingState { public String label() { return "READY"; } public boolean insertCoin(VendingMachine vm, Coin coin) { vm.addToEscrow(coin); vm.setState(new HasMoneyState()); return true; } public Optional<Sale> select(VendingMachine vm, String code) { vm.display("Insert coins first"); return Optional.empty(); } public List<Coin> cancel(VendingMachine vm) { return List.of(); } } class HasMoneyState implements VendingState { public String label() { return "HAS_MONEY"; } public boolean insertCoin(VendingMachine vm, Coin coin) { vm.addToEscrow(coin); return true; } public Optional<Sale> select(VendingMachine vm, String code) { Optional<Slot> found = vm.getInventory().find(code); if (found.isEmpty()) { vm.display("Unknown code " + code); return Optional.empty(); } Slot slot = found.get(); if (slot.isEmpty()) { vm.display(code + " is sold out, choose another item"); return Optional.empty(); } int price = slot.getProduct().priceCents(); if (vm.balance() < price) { vm.display("Insert " + (price - vm.balance()) + "c more for " + slot.getProduct().name()); return Optional.empty(); } Optional<List<Coin>> change = vm.getCoinBox().planChange(vm.balance() - price, vm.getEscrow()); if (change.isEmpty()) { vm.display("Cannot make change for " + code + ", choose another item or cancel"); return Optional.empty(); } // Every check passed: commit escrow, pay change, dispense. vm.getCoinBox().deposit(vm.getEscrow()); vm.getCoinBox().withdraw(change.get()); slot.takeOne(); vm.clearEscrow(); vm.setState(vm.getInventory().allSoldOut() ? new OutOfServiceState() : new ReadyState()); return Optional.of(new Sale(slot.getProduct(), change.get())); } public List<Coin> cancel(VendingMachine vm) { List<Coin> refund = new ArrayList<>(vm.getEscrow()); vm.clearEscrow(); vm.setState(new ReadyState()); return refund; } } class OutOfServiceState implements VendingState { public String label() { return "OUT_OF_SERVICE"; } public boolean insertCoin(VendingMachine vm, Coin coin) { vm.display("Out of service, coin returned"); return false; } public Optional<Sale> select(VendingMachine vm, String code) { vm.display("Out of service"); return Optional.empty(); } public List<Coin> cancel(VendingMachine vm) { return List.of(); } } class VendingMachine { private final Inventory inventory = new Inventory(); private final CoinBox coinBox = new CoinBox(); private final List<Coin> escrow = new ArrayList<>(); private VendingState state = new ReadyState(); // Customer panel synchronized boolean insertCoin(Coin coin) { return state.insertCoin(this, coin); } synchronized Optional<Sale> select(String code) { return state.select(this, code); } synchronized List<Coin> cancel() { return state.cancel(this); } // Operator console: refund any customer in progress before servicing synchronized List<Coin> enterService() { List<Coin> refund = state.cancel(this); state = new OutOfServiceState(); return refund; } synchronized void exitService() { if (!(state instanceof OutOfServiceState)) throw new IllegalStateException("Not in service mode"); state = inventory.allSoldOut() ? new OutOfServiceState() : new ReadyState(); } synchronized void restock(String code, int count) { requireService(); inventory.restock(code, count); } synchronized void loadCoins(Coin coin, int count) { requireService(); coinBox.deposit(Collections.nCopies(count, coin)); } private void requireService() { if (!(state instanceof OutOfServiceState)) throw new IllegalStateException("Enter service mode first"); } // Helpers used by states (package-private, not part of the customer API) Inventory getInventory() { return inventory; } CoinBox getCoinBox() { return coinBox; } List<Coin> getEscrow() { return Collections.unmodifiableList(escrow); } void addToEscrow(Coin coin) { escrow.add(coin); display("Balance " + balance() + "c"); } void clearEscrow() { escrow.clear(); } int balance() { return escrow.stream().mapToInt(c -> c.cents).sum(); } void setState(VendingState next) { state = next; } void display(String message) { System.out.println(" [screen] " + message); } synchronized String status() { return state.label() + " " + inventory + " coins=" + coinBox; } } public class Main { static void buy(VendingMachine vm, String code) { vm.select(code).ifPresent(s -> System.out.println(" DISPENSED " + s.product().name() + ", change " + s.change())); } public static void main(String[] args) { VendingMachine vm = new VendingMachine(); vm.enterService(); vm.getInventory().addSlot("A1", new Product("Chips", 65), 2); vm.getInventory().addSlot("A2", new Product("Soda", 125), 1); vm.getInventory().addSlot("B1", new Product("Gum", 70), 0); vm.loadCoins(Coin.DIME, 1); vm.exitService(); System.out.println("Start: " + vm.status()); System.out.println("1) Select before paying"); buy(vm, "A1"); System.out.println("2) Three quarters for chips (65c)"); for (int i = 0; i < 3; i++) vm.insertCoin(Coin.QUARTER); buy(vm, "A1"); System.out.println("3) Dollar for chips, but no coins for 35c change"); vm.insertCoin(Coin.DOLLAR); buy(vm, "A1"); System.out.println(" refunded " + vm.cancel()); System.out.println("4) Soda (125c) paid in two steps"); vm.insertCoin(Coin.DOLLAR); buy(vm, "A2"); vm.insertCoin(Coin.QUARTER); buy(vm, "A2"); System.out.println("5) Operator restocks gum and loads dimes"); vm.enterService(); vm.insertCoin(Coin.QUARTER); vm.restock("B1", 2); vm.loadCoins(Coin.DIME, 3); vm.exitService(); System.out.println(" " + vm.status()); System.out.println("6) Dollar for gum (70c): 30c change with no nickels"); vm.insertCoin(Coin.DOLLAR); buy(vm, "A2"); buy(vm, "B1"); System.out.println("End: " + vm.status()); } }
Demo output
Start: READY [A1:Chipsx2, A2:Sodax1, B1:Gumx0] coins={NICKEL=0, DIME=1, QUARTER=0, DOLLAR=0}
1) Select before paying
[screen] Insert coins first
2) Three quarters for chips (65c)
[screen] Balance 25c
[screen] Balance 50c
[screen] Balance 75c
DISPENSED Chips, change [DIME]
3) Dollar for chips, but no coins for 35c change
[screen] Balance 100c
[screen] Cannot make change for A1, choose another item or cancel
refunded [DOLLAR]
4) Soda (125c) paid in two steps
[screen] Balance 100c
[screen] Insert 25c more for Soda
[screen] Balance 125c
DISPENSED Soda, change []
5) Operator restocks gum and loads dimes
[screen] Out of service, coin returned
READY [A1:Chipsx1, A2:Sodax0, B1:Gumx2] coins={NICKEL=0, DIME=3, QUARTER=4, DOLLAR=1}
6) Dollar for gum (70c): 30c change with no nickels
[screen] Balance 100c
[screen] A2 is sold out, choose another item
DISPENSED Gum, change [DIME, DIME, DIME]
End: READY [A1:Chipsx1, A2:Sodax0, B1:Gumx1] coins={NICKEL=0, DIME=0, QUARTER=4, DOLLAR=2}
Walkthrough:
- Step 2 uses the only dime in the machine as change, which sets up the next scenario.
- Step 3 is the case candidates usually miss: 35 cents of change needs a dime or nickels, and there are none. The sale is refused before anything moves, and cancel returns the very dollar coin that was inserted, straight from the escrow.
- Step 4 shows the machine staying in
HasMoneywhile the balance is short, then completing once the extra quarter arrives. - Step 5: a coin inserted during service mode is rejected by
OutOfServiceState. - Step 6: the backtracking change maker returns three dimes. A greedy one would have taken a quarter, been left owing 5 cents, and refused the sale.
- The final coin box adds up: every coin inserted for a completed sale is in the box, and the only coins that left were change.
Concurrency Considerations
A vending machine serves one customer at a time, but there are still several independent inputs: the coin acceptor, the keypad, the cancel button and the operator console (or a remote management API). If two of them act at the same moment, a naive design can double-dispense or refund coins that were already moved to the cash box.
- One lock for the whole machine. Every public method on
VendingMachineissynchronized, so each action sees a consistent state, escrow and inventory. Contention is not a concern for a single physical machine, and a coarse lock is far easier to reason about than per-slot locks. - States are stateless objects. All mutable data lives in the context, so the states need no locking of their own.
- Check, then commit.
HasMoneyState.selectperforms every check (slot, stock, balance, change plan) before mutating anything. If the change plan fails, nothing has moved, so the customer can pick something else or cancel. - Service mode refunds first.
enterServicecallscancelon the current state, so a customer who walked away mid-session gets their coins back rather than having them silently absorbed.
Hardware can fail after the software commits (a spiral jams and the product does not drop). Production machines add a DispensingState that waits for a drop sensor; on timeout they roll back the sale by returning the escrowed amount and marking the slot faulty.
Extensions
| Requirement | Change |
|---|---|
| Card / mobile payments | A PaymentMethod interface (CoinPayment, CardPayment); a CardAuthorizingState that waits for the processor before vending |
| Dispense confirmation | DispensingState that completes on a sensor event or rolls back on timeout |
| Low-stock and low-change alerts | Observer: Inventory and CoinBox publish events; a telemetry service subscribes |
| Dynamic pricing / promotions | PricingStrategy (happy hour, combo discount) consulted in select |
| "Exact change only" light | CoinBox exposes canMakeChangeUpTo(maxChange); the display shows the warning when false |
| Bill acceptor | Add Bill values; cap the escrow so the machine never accepts more than it can change |
| Multiple products per slot / spirals | Slot holds a queue of items with expiry dates; the oldest is dispensed first |
Interview Tips
- Draw the state diagram first; then each state class is just one row of that diagram.
- Name the escrow explicitly. It explains how cancel refunds the exact coins and why the cash box is never wrong.
- Mention that greedy change can fail with limited coins, and show the backtracking fix or a small DP.
- Keep money in integer cents and say why (no floating-point rounding).
- Discuss what happens when the hardware fails after the software commits; it shows you think about real devices.
- Keep the operator flow (service mode, restock, refill coins) separate from the customer flow.