Vending Machine LLD: Where the State Pattern Earns Its Keep
A low-level design walkthrough of a vending machine: coins, inventory, the can't-make-change trap, and when design patterns earn their keep — the honest path from if-checks to the State pattern.
"Design a vending machine." It sounds like a hardware question, but it's the purest software interview there is: a box with money on one side, products on the other, and in between — a workflow that must never lose a rupee, never dispense for free, and never eat your coins without recourse. Every bug in this design has an angry customer kicking the real machine somewhere.
It's also the question where design patterns stop being vocabulary and start being load-bearing. The rookie's guide cheat sheet promised that the State pattern shows up when behavior changes with lifecycle — this is the machine it was talking about. Let's build it: coins, stock, the change-making trap almost everyone forgets, and the honest answer to "should this be the State pattern?"
Let's start nowhere near a computer
Stand in front of the office vending machine and watch what it won't do. Before you've inserted a coin, the buttons do nothing — press B4 all you want. After you've fed it ₹10, the coin slot still works (add more), the buttons now work (buy), and the little lever works (give up, get your coins back). The machine isn't running one set of rules; it's running a different rulebook depending on what state it's in.
That observation is the design. The same button press means "ignore" in one state and "dispense" in another — and our whole job is to make those rulebooks explicit instead of burying them in nested ifs.
You're surrounded by this machine
- Every checkout flow is a vending machine: cart → payment pending → paid → fulfilled, with different buttons live at each step.
- Order lifecycles — the PLACED → ACCEPTED → … machine from the HLD guide is this box at database scale.
- The parking ticket from the rookie's guide — ACTIVE → PAID → EXITED — is a two-transition vending machine.
- TCP connections, Bluetooth pairing, OAuth flows — "what's allowed right now depends on where we are" is half of systems programming.
Step 1 — Functional requirements (sentences first)
What the machine must do, as plain sentences — the functional requirements.
- A customer can insert coins, building a balance.
- A customer can buy a product if the balance covers its price; the machine dispenses it and the change.
- A customer can cancel anytime and get their coins back.
- The machine refuses a sale when the product is out of stock — or when it cannot make exact change.
- An operator can stock products and load coins for change.
That fourth sentence is where this problem hides its teeth. Hold onto it — it's the one requirement that turns a glorified if into a machine that has to reason about its own till before it commits.
Step 2 — Non-functional requirements
At class level the non-functional requirements are different words for the same idea — how well, not just what — and for a vending machine they're all about the money never lying:
- Correctness. Charge exactly the price, hand back exact change, never dispense without full payment, and keep inventory reconciled with what physically left the machine. Every off-by-a-rupee here is a customer kicking the box.
- Safety / atomicity. Take the money, dispense the product, and give change as one all-or-nothing act — if any leg can't complete, the whole sale unwinds and the customer's coins stay refundable.
- Extensibility. New states (
DISPENSING,OUT_OF_ORDER), new products, and new payment types should plug into the workflow without rewriting the ones already there. - Testability. Each transition — insert, buy, cancel — must be checkable on its own, with the failure paths (insufficient funds, can't-make-change, sold-out) reachable in a unit test, not just on real hardware.
Listing them is the easy half; the design only earns them if it fulfills them. Here's the contract — each requirement and the mechanism that keeps it:
| Requirement | How this design fulfills it |
|---|---|
| Correctness | money as integer minor units (no float drift); change computed against the real float before anything moves — Steps 3, 5 |
| Safety / atomicity | buy does every refusal before the irreversible dispense, and rolls the accepted coins back out if change fails — Step 6 |
| Extensibility | states are explicit, ready to become one-class-per-state; payment hides behind a future Payment seam — Steps 4, 7 |
| Testability | each verb is a pure transition over pending/bank/inventory, so every failure path is a deterministic unit test — Steps 4, 6 |
Every trade-off below is chosen to keep one of these.
Step 3 — Nouns → classes
The nouns: machine, coin, product, balance, inventory, change, bank. The cast that survives the "own state or own lifecycle" test:
- Coin — an enum with a value. Money as denominations, not floats.
- Product — code, name, price. A record.
- Inventory — products and their stock counts; its own state, its own class.
- CashBank — the float of coins inside the machine, and the skill of making change. This is the class rookies forget to create, and it's where the hard logic lives.
- VendingMachine — the conductor: holds the state, the pending coins, and the workflow.
Balance? Just the sum of pending coins — a method, not a field to desynchronize.
Which structure — and why. Money is an int of minor units (rupees), never a double — floats keep correctness honest by making 7 + 3 == 10 a fact, not a rounding hope. The float lives in a Coin → count EnumMap, because making change is denomination-by-denomination arithmetic, and the map lets makeChange ask "how many fives do I still have?" in O(1) while a tentative copy keeps the safety promise — nothing is committed until the change is proven makeable. Pending coins are a plain List<Coin> rather than folded into the bank, so a refund can hand back the same physical coins and never depend on the float having change — that single choice is what keeps cancel atomic. Each structure is picked to keep an NFR: integer money keeps correctness, the tentative-copy EnumMap keeps safety, the separate pending list keeps both.
Step 4 — Verbs → methods (and the state machine appears)
The verbs — insert, buy, cancel — all belong to the machine. But their meaning depends on the state, which is exactly what we saw at the office machine:
Two states are enough for v1: IDLE (no money) and HAS_MONEY. Each transition is one of our sentences; the diagram is the requirements, redrawn.
public synchronized void insertCoin(Coin coin) {
pending.add(coin);
state = State.HAS_MONEY;
}
public synchronized List<Coin> cancel() {
List<Coin> refund = List.copyOf(pending); // your exact coins back
pending.clear();
state = State.IDLE;
return refund;
}Note cancel returns the same physical coins you inserted. That design choice quietly dodges a whole bug class: refunds can never fail for lack of change, because the machine never mingles your coins with its float until a sale actually completes.
Step 5 — The trap: a machine that can't make change
Here's the sentence everyone skips: the customer pays ₹10 for a ₹7 item, and the machine owes ₹3 — out of whatever coins it happens to hold.
If the bank is all fives, that ₹3 does not exist. The naive design discovers this after dispensing — the customer gets the snack and a shrug. The correct order of operations: make the change first, dispense second, and if the change can't be made, refuse the whole sale with the customer's coins still refundable.
/** Largest coins first; empty if exact change is impossible with this float. */
Optional<List<Coin>> makeChange(int amount) {
List<Coin> change = new ArrayList<>();
EnumMap<Coin, Integer> tentative = new EnumMap<>(coins);
for (Coin coin : new Coin[] {Coin.TEN, Coin.FIVE, Coin.TWO, Coin.ONE}) {
while (amount >= coin.value() && tentative.getOrDefault(coin, 0) > 0) {
amount -= coin.value();
tentative.merge(coin, -1, Integer::sum);
change.add(coin);
}
}
if (amount != 0) {
return Optional.empty(); // nothing was touched — the sale can be refused safely
}
coins.clear();
coins.putAll(tentative); // commit only on success
return Optional.of(change);
}Greedy is the right interview v1 — but say its limit out loud: with a limited float it can fail where a smarter search succeeds. Owe ₹6 holding {5, 2, 2, 2}: greedy grabs the 5, needs ₹1, dies — yet 2+2+2 was sitting right there. The fix is a small dynamic-programming search over the float; naming that trade-off unprompted is a senior flex.
Step 6 — buy: the whole machine in one method
Now the conductor's big verb, with every refusal happening before anything irreversible:
public synchronized List<Coin> buy(String code) {
if (state != State.HAS_MONEY) {
throw new IllegalStateException("insert coins first");
}
Product product = inventory.productOrThrow(code);
if (!inventory.inStock(code)) {
throw new IllegalStateException("out of stock: " + code);
}
int balance = balance();
if (balance < product.price()) {
throw new IllegalStateException("insufficient balance: need ₹" + (product.price() - balance) + " more");
}
pending.forEach(bank::accept); // money joins the float…
Optional<List<Coin>> change = bank.makeChange(balance - product.price());
if (change.isEmpty()) {
pending.forEach(bank::remove); // …or backs straight out
throw new IllegalStateException("cannot make change — add smaller coins or cancel");
}
inventory.dispense(code); // only now is anything irreversible
pending.clear();
state = State.IDLE;
return change.get();
}Walk the failure paths like an interviewer will: insufficient balance? Coins still pending — add more or cancel. Can't make change? The accept is rolled back, coins still pending, customer chooses. Out of stock? Nothing moved at all. The machine never reaches a state it can't gracefully leave — that sentence is the whole grade.
Step 7 — So… is this the State pattern?
The honest answer, and the one that wins rooms: not yet. Two states and one if per method don't justify a class per state — that's the cheat-sheet's last row, plain code.
But you should know exactly where the line is. Watch the states×actions grid grow: add DISPENSING (hardware takes time), OUT_OF_ORDER (maintenance), EXACT_CHANGE_ONLY (low float)… now five states × four actions = twenty behaviors tangled through every method as nested conditionals. That's the moment the pattern picker fires — behavior varies by lifecycle — and you refactor each rulebook into its own class:
interface MachineState {
MachineState insertCoin(VendingMachine m, Coin coin);
MachineState buy(VendingMachine m, String code);
MachineState cancel(VendingMachine m);
}
// IdleState, HasMoneyState, OutOfOrderState… each a rulebook,
// each returning the next state — the diagram becomes the code, one class per box.Knowing the refactor and choosing not to apply it yet is the strongest pattern answer there is.
Step 8 — Trade-offs (each one keeping an NFR)
The last column is the discipline: every choice keeps one of the promises from Step 2 — that's what designing to the non-functional requirements looks like.
| Decision | The tempting alternative | Why ours wins | Keeps |
|---|---|---|---|
| money as integer minor units | double rupees | 7 + 3 is exactly ₹10, not ₹9.9999 — no rounding can cheat the customer or the till | correctness |
| change made, then dispense — never before | dispense, then settle the money | a sale that can't complete unwinds entirely; the snack never drops on a bad till | safety |
| refund the customer's own pending coins | refund out of the float | cancel can't fail for lack of change — the float is never touched until a sale lands | safety |
two plain states + one if per method | a State class per box now | ceremony you can't justify is a cost; the seam is ready when the grid earns it | extensibility |
| each verb a pure transition over state | logic woven into hardware calls | every failure path — funds, change, stock — is a deterministic unit test | testability |
Growth — when the grid and the float outgrow v1. The first pressure is the states×actions grid from Step 7: once DISPENSING and OUT_OF_ORDER arrive, the if-per-method cost crosses the line and the State classes pay for themselves. The second is the float itself — greedy change-making fails on a limited till where a smarter search wouldn't, so the change-maker grows from greedy to a small DP over the denominations (the ₹6-from-2 case). Neither is a rewrite; both are seams this design already left open.
The complete implementation
package dev.fiveyear.vending;
public enum Coin {
ONE(1), TWO(2), FIVE(5), TEN(10);
private final int value;
Coin(int value) {
this.value = value;
}
public int value() {
return value;
}
}
public record Product(String code, String name, int price) {}package dev.fiveyear.vending;
import java.util.HashMap;
import java.util.Map;
public final class Inventory {
private final Map<String, Product> products = new HashMap<>();
private final Map<String, Integer> stock = new HashMap<>();
public void add(Product product, int quantity) {
products.put(product.code(), product);
stock.merge(product.code(), quantity, Integer::sum);
}
Product productOrThrow(String code) {
Product product = products.get(code);
if (product == null) {
throw new IllegalArgumentException("unknown product: " + code);
}
return product;
}
boolean inStock(String code) {
return stock.getOrDefault(code, 0) > 0;
}
void dispense(String code) {
if (!inStock(code)) {
throw new IllegalStateException("out of stock: " + code);
}
stock.merge(code, -1, Integer::sum);
}
}package dev.fiveyear.vending;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Optional;
/** The machine's float: the coins it holds, and the skill of making change. */
public final class CashBank {
private final EnumMap<Coin, Integer> coins = new EnumMap<>(Coin.class);
public void load(Coin coin, int count) {
coins.merge(coin, count, Integer::sum);
}
void accept(Coin coin) {
coins.merge(coin, 1, Integer::sum);
}
void remove(Coin coin) {
coins.merge(coin, -1, Integer::sum);
}
/** Largest coins first; empty if exact change is impossible with this float. */
Optional<List<Coin>> makeChange(int amount) {
List<Coin> change = new ArrayList<>();
EnumMap<Coin, Integer> tentative = new EnumMap<>(coins);
for (Coin coin : new Coin[] {Coin.TEN, Coin.FIVE, Coin.TWO, Coin.ONE}) {
while (amount >= coin.value() && tentative.getOrDefault(coin, 0) > 0) {
amount -= coin.value();
tentative.merge(coin, -1, Integer::sum);
change.add(coin);
}
}
if (amount != 0) {
return Optional.empty();
}
coins.clear();
coins.putAll(tentative);
return Optional.of(change);
}
}package dev.fiveyear.vending;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public final class VendingMachine {
public enum State { IDLE, HAS_MONEY }
private final Inventory inventory;
private final CashBank bank;
private final List<Coin> pending = new ArrayList<>();
private State state = State.IDLE;
public VendingMachine(Inventory inventory, CashBank bank) {
this.inventory = inventory;
this.bank = bank;
}
public synchronized void insertCoin(Coin coin) {
pending.add(coin);
state = State.HAS_MONEY;
}
/** Dispenses the product and returns the change coins. */
public synchronized List<Coin> buy(String code) {
if (state != State.HAS_MONEY) {
throw new IllegalStateException("insert coins first");
}
Product product = inventory.productOrThrow(code);
if (!inventory.inStock(code)) {
throw new IllegalStateException("out of stock: " + code);
}
int balance = balance();
if (balance < product.price()) {
throw new IllegalStateException(
"insufficient balance: need ₹" + (product.price() - balance) + " more");
}
pending.forEach(bank::accept);
Optional<List<Coin>> change = bank.makeChange(balance - product.price());
if (change.isEmpty()) {
pending.forEach(bank::remove);
throw new IllegalStateException("cannot make change — add smaller coins or cancel");
}
inventory.dispense(code);
pending.clear();
state = State.IDLE;
return change.get();
}
/** Returns the customer's own coins, untouched. */
public synchronized List<Coin> cancel() {
List<Coin> refund = List.copyOf(pending);
pending.clear();
state = State.IDLE;
return refund;
}
public synchronized int balance() {
return pending.stream().mapToInt(Coin::value).sum();
}
public synchronized State state() {
return state;
}
}A customer at the machine:
Inventory inventory = new Inventory();
inventory.add(new Product("A1", "chips", 7), 2);
CashBank bank = new CashBank();
bank.load(Coin.ONE, 5); // float for making change
VendingMachine machine = new VendingMachine(inventory, bank);
machine.insertCoin(Coin.TEN); // balance ₹10, state HAS_MONEY
List<Coin> change = machine.buy("A1"); // dispenses chips
// change = [ONE, ONE, ONE] → ₹3 back, state IDLE
machine.insertCoin(Coin.FIVE);
machine.buy("A1"); // throws — insufficient: need ₹2 more
machine.insertCoin(Coin.TWO);
machine.buy("A1"); // ₹7 exact — no change, dispensed
machine.buy("A1"); // throws — insert coins first (and it's sold out anyway)The interview corner
Clarify before you code: Which denominations exist? Can a price be unpayable with the smallest coin? What should survive a power cut mid-purchase? That last one earns respect before a line is written.
The follow-up ladder:
- "Add OUT_OF_ORDER and EXACT_CHANGE_ONLY." The states×actions grid finally outgrows ifs — this is the State-classes refactor from Step 7, now earning its keep.
- "Greedy change fails on some floats — fix it." Count-limited coin change is a small DP over the float; the ₹6-from-2 case becomes your test.
- "Two buyers at once?" One physical machine is one workflow: a single
synchronizedboundary is the honest answer; a fleet gets a lock per machine. - "Add card payments." Payment now varies → a
Paymentstrategy (coins, card, UPI) behind one interface;pendinggeneralizes into a payment session. - "The operator wants an audit." Append every insert, dispense, and refund to an event log — the ledger never guesses (the jackpot's discipline).
Mistakes that fail the round: dispensing before the change is secured; mixing the customer's coins into the float before the sale completes; ceremony-driven State classes for a two-state machine.
Where to go from here
The machine in one breath: states make the rulebooks explicit, the bank owns the money skill, every refusal happens before anything irreversible, and the State pattern waits until the grid earns it.
- Grow the grid. Add
OUT_OF_ORDERandEXACT_CHANGE_ONLY, do the State-classes refactor, and feel the moment the pattern flips from ceremony to relief. - Upgrade the change-maker from greedy to a DP search over the float — then test it on the ₹6-from-2 case that greedy fumbles.
- Previous machine in the queue: Tic-Tac-Toe, where the right pattern answer was none at all. The contrast between these two articles is the entire pattern-judgment skill.
Next time the office machine blinks "EXACT CHANGE ONLY," you'll know exactly which state it's in, which rulebook just hot-swapped — and which Optional somewhere inside came back empty.