Vending machine
Part of the free Low-Level Design course on LogicWiz, in the chapter: Case studies, step by step. The class-diagram exercises run in the app.
Vending machine: when every button depends on the past
"The machine took a coin while the door was open. Which of these four if statements forgot the new state?"
Priya, reading a bug report
The prompt, and the questions about money and failure
Priya says: "Design a vending machine for an office. It sells snacks and drinks from numbered slots. Users insert coins, choose a product, and get the product and their change." Arjun uses his five kinds of question again, and the failures matter most here, because money is involved.
- Which money? Coins of 1, 2, 5, 10 and 20 rupees. Notes and UPI are an extension.
- Several coins for one purchase? Yes. The balance builds up until the user chooses.
- What if exact change is not possible? Refuse the sale, keep the balance, and let the user cancel or pick another item.
- One product per purchase? Yes. Then the machine returns change and waits again.
- Can the user cancel? Yes, at any time before the product drops.
- Who refills it? An operator, with a
restock(code, count)call and no login.
Each answer is a rule the design must obey. Arjun writes the failure answer on the board in capital letters: never take money you cannot settle.
From nouns and verbs to classes
The story is short. A user puts coins in. The machine adds up a balance. The user picks a slot. If the slot has stock, the balance covers the price and change is possible, the machine drops the product and returns change. The user may cancel and get the coins back.
The nouns become classes with one test: does it have data of its own and a rule of its own?
- Coin is an enum with a value: 1, 2, 5, 10 or 20.
- Product has a name and a price.
- Slot has a code, a product and a count. Stock belongs to the slot, not the product, because the same product can sit in two slots.
- Inventory holds the slots and knows whether all of them are empty.
- CoinBox holds the machine's own coins and owns the change rule, so no other class edits the counts.
- VendingMachine receives the buttons.
The operator, the display and the motor are out of scope. Now look at the verbs. Insert, select and cancel are buttons, and what each does depends on what happened before. Selecting after paying drops a product. Selecting before paying gives an error. The same button answers differently because of history, and history is the noun missing from the list: the machine's state.
The first draft, and where it breaks
Arjun keeps the state in an enum and checks it in every button method.
public class NaiveVendingMachine {
enum State { IDLE, HAS_MONEY, SOLD_OUT, MAINTENANCE }
State state = State.IDLE;
private int balance;
private final Map<String, Integer> price = new HashMap<>();
private final Map<String, Integer> stock = new HashMap<>();
public void startMaintenance() { state = State.MAINTENANCE; }
public void insertCoin(int coin) {
if (state == State.SOLD_OUT) {
System.out.println("Sold out, coin returned");
} else {
balance += coin;
state = State.HAS_MONEY;
}
}
public void select(String code) {
if (state == State.IDLE) {
System.out.println("Insert money first");
} else if (state == State.HAS_MONEY) {
if (stock.get(code) == 0) System.out.println("Slot empty");
else if (balance < price.get(code)) System.out.println("Insert more money");
else {
stock.merge(code, -1, Integer::sum);
System.out.println("Dropped " + code + ", change " + (balance - price.get(code)));
balance = 0;
state = State.IDLE;
}
} else if (state == State.SOLD_OUT) {
System.out.println("Sold out");
}
}
}
Priya asks for maintenance mode, and Arjun adds MAINTENANCE to the enum and a startMaintenance method. Then she runs it: the operator starts maintenance, a coin goes in, and a product is selected.
state: MAINTENANCE
state: HAS_MONEY
Dropped A2, change 0
The machine left maintenance by itself. insertCoin only knows about SOLD_OUT, so the new state fell into the else branch and was treated as normal. Arjun edited one method and forgot the others, and nothing warned him. The same pattern hides in cancel and restock, which are not shown.
Priya then inserts a 20 rupee coin for a 10 rupee drink. The machine prints Dropped A2, change 10. It returned change from thin air, because the machine never recorded which coins it holds. Two pains: every new state edits every method, and the money is only a number.
Two decisions: one class per state, and change that rolls back
Decision 1: each state becomes a class that answers every button. Four states and three buttons make twelve answers. In the first draft they are spread over three methods. Priya draws them on one picture:
stateDiagram-v2
[*] --> Idle
Idle --> HasMoney: insertCoin
HasMoney --> HasMoney: insertCoin
HasMoney --> Idle: cancel, refund coins
HasMoney --> Dispensing: select [stock, price, change all fine]
Dispensing --> Idle: item dropped [stock left]
Dispensing --> SoldOut: item dropped [all slots empty]
SoldOut --> Idle: restock
"Each box is a class, and each arrow is a method that picks the next box." This is the State pattern from the earlier lesson, used on a whole machine. A MachineState interface has one method per button, and VendingMachine hands every press to its current state:
public synchronized void insertCoin(Coin c) { state.insertCoin(this, c); }
public synchronized void selectProduct(String code) { state.selectProduct(this, code); }
public synchronized void cancel() { state.cancel(this); }
IdleState.selectProduct throws "Insert money first". SoldOutState.insertCoin gives the coin back. No if on state remains. The states receive the machine as an argument and call m.setState(...) to move, so the state decides what comes next.
Decision 2: nothing moves until every check has passed. The busiest answer is HasMoneyState.selectProduct. It checks stock, then price, then change, in that order, and only then changes the state and drops the product:
Slot slot = m.inventory().slot(code);
if (slot.isEmpty()) throw new IllegalStateException("Sold out: " + code);
int price = slot.product().price();
if (m.balance() < price) {
throw new IllegalStateException("Insert " + (price - m.balance()) + " more rupees");
}
List<Coin> change = m.coinBox().acceptPayment(m.inserted(), m.balance() - price)
.orElseThrow(() -> new IllegalStateException("No change for this item. Pick another or cancel."));
m.setState(m.dispensing);
m.dispense(slot, change);
Think of a shopkeeper who counts the change onto the counter first, and takes your note only after that. If the drawer is short, you keep your note. The picture stops working in one place: a shopkeeper can fetch coins from next door, and the machine cannot.
The same rule holds in the code: a refusal at any line leaves the machine as it was. This is also why the inserted coins wait in a separate inserted list. They move into the coin box only when the sale goes ahead. Cancelling is then easy: return the list.
The change itself comes from CoinBox.acceptPayment, which takes the paid coins and picks change from the largest coin down:
public Optional<List<Coin>> acceptPayment(List<Coin> paid, int change) {
paid.forEach(c -> counts.merge(c, 1, Integer::sum));
List<Coin> out = new ArrayList<>();
int left = change;
Coin[] largestFirst = Coin.values();
for (int i = largestFirst.length - 1; i >= 0; i--) {
Coin c = largestFirst[i];
while (left >= c.value() && counts.getOrDefault(c, 0) > 0) {
counts.merge(c, -1, Integer::sum);
out.add(c);
left -= c.value();
}
}
if (left == 0) return Optional.of(out);
out.forEach(c -> counts.merge(c, 1, Integer::sum)); // undo the change taken
paid.forEach(c -> counts.merge(c, -1, Integer::sum)); // undo the payment
return Optional.empty();
}
This is greedy change: always take the biggest coin that still fits. If change cannot be made, the method puts every coin back and returns an empty result. The machine starts with no coins. These are the real outputs of Arjun's run, with state names in brackets:
select A1
Refused: Insert money first
[IdleState]
insert 10
[HasMoneyState]
select A1
Refused: Insert 5 more rupees
[HasMoneyState]
insert 5
[HasMoneyState]
select A1
Dropped Chips
Returned []
[IdleState]
The machine now holds a 10 and a 5. Next a 20 goes in for the 10 rupee Water: the change is one 10 from the box, so the output is Dropped Water and Returned [TEN]. After that the box holds {FIVE=1, TWENTY=1}. Arjun inserts another 20 and selects the 12 rupee Juice, which needs 8 in change. The box has a 5 and no 2s or 1s:
coin box before: {FIVE=1, TWENTY=1}
select B1
Refused: No change for this item. Pick another or cancel.
coin box after: {FIVE=1, TWENTY=1}
cancel
Returned [TWENTY]
The sale was refused, the box is exactly as before, and cancel returns the coin.
Extend it, then break it
"Add maintenance mode." Arjun adds one class, MaintenanceState, which refuses every user button. He adds startMaintenance and endMaintenance to the interface as default methods that refuse, so only IdleState, SoldOutState and the new class override them. This is the same run as the one that failed, now correct:
-- maintenance is one new class
[MaintenanceState]
insert 10
Refused: Out of service
[MaintenanceState]
A forgotten button now refuses the user by default, where the old code accepted money by default. A safe default is a design choice, and you make it once, in the interface.
"The operator restocks while someone is buying." Every button on VendingMachine is synchronized, which means one thread at a time on the machine. Parking spots locked only the spot, so why lock the whole machine here? A machine serves one person at human speed, and every button touches the same balance, state and coins. One lock is simple and costs nothing noticeable. In the parking lot, many gates competed for many independent spots, so a smaller lock paid off.
"Can the change step fail even when change exists?" Yes, and Arjun shows it. A box holds one 5 and three 2s. Six rupees can be paid as 2+2+2, but greedy takes the 5 first, finds 1 left, and gives up:
coin box: {TWO=3, FIVE=1}, change for 6: Optional.empty
Refusing is safe, and a search that tries other combinations, such as dynamic programming, finds 2+2+2. In an interview, name the limit and say that refusing a sale is better than losing money. Last, a stuck item: with a real motor, DispensingState would last a few seconds and refuse every button until a sensor confirms the drop. If the sensor sees nothing, the machine refunds. Here the drop is instant, so the state lasts one call.
💡 Tip: When a design question starts with "what if this fails halfway?", look for the order of steps. Check first, change second, and keep a way to undo.
Your turn: give each state a class
Start from a machine that keeps an enum and a pile of money numbers. Add the state interface, at least three state classes, and a separate class that owns the change rule.
📌 Summary: When one button means different things over time, give each state a class. Put the safe refusal in the interface. Check everything before anything moves, and let the class that owns the coins roll back.
Next, open the problem Vending Machine in the problem library. Design it again from a blank page, compare it with the model answer, and then use its "Build it on your machine" kit to write and run it.
What Arjun learns next
The vending machine only had coins in a box. Priya's next machine talks to a real bank, checks a PIN and counts out notes. It needs the State pattern again, plus a second idea for choosing which notes to hand over.
Check your understanding
- Arjun adds a MAINTENANCE state and forgets to update one button. Which design makes the forgotten case safe?
- A user inserts 20 rupees for a 12 rupee item, and the coin box cannot make 8 rupees. What should the machine do?
- Where does the count of items in stock belong in the design?
- Every button on VendingMachine is synchronized, while ParkingSpot locks only the spot. Why is the wide lock right here?