Skip to content

Low Level DesignWorked problems

Design a vending machine

Design a vending machine step by step for a low level design interview. The State pattern done properly, change making that greedy gets wrong, escrow, jams, and a single event loop, with code in Java, Python, C#, Go and TypeScript.

Foundation29 min core · 45 min full depth

“Design a vending machine.” The interviewer usually adds nothing else. They’re waiting to see whether you use the State pattern for a reason, or out of habit.

The vending machine is the classic State pattern question, and that’s the trap. Most answers draw four state classes in the first five minutes and never explain why the states need to be classes at all.

The questions that decide the interview come later. The machine owes 30 cents and holds one quarter and three dimes: what does it give back? The motor turns but the bag gets stuck: who has the money now? Someone presses cancel while the motor is running: what happens?

If you only have a minute, this is the design. The rest of the page explains how we get here.

  1. The machine’s behavior depends on what it’s doing right now. A coin means “add credit” when it’s waiting for money, but “give it back” while it’s dropping an item. So each situation is a state class, and the machine passes every event to its current state.
  2. Change comes from the real coins inside the machine. The usual “largest coin first” method can fail when coins run low, so we use a search that backs off and tries smaller coins.
  3. The customer’s coins are kept apart until the sale is done. If they cancel, they get their own coins back. This holding area is called escrow.
  4. A sale only counts once the item has actually dropped. We reserve everything first, run the motor, and let the drop sensor decide: complete the sale, or give the money back.
  5. One event at a time. The coin slot, the keypad and the drop sensor all send events. They wait in one queue and are handled in order, so a sale is never interrupted halfway.

We’ll build this in four stages, then deal with several devices sending events at once.

Before drawing anything, we ask a few questions. Each answer changes a class. Anything that doesn’t, we assume and move on.

Interviewerminute 0

Design a vending machine.

What they are testing

Whether you ask about the two things that shape the design (how people pay, and what “giving change” means) within three minutes. Asking about product categories or a touchscreen looks like filling time. Asking nothing usually ends in a design that can’t give change.

Here are the questions that matter:

  • How do people pay? Coins: 5, 10, 25 and 100 cents. Cards come later as an extension. With cards only, there’s no change to give, and the hardest part goes away.
  • Does it give change, and from where? Yes, but only from the coins inside the machine. If it had unlimited coins, giving change would be simple subtraction.
  • Can the customer cancel? Yes, until the motor starts. They get their own coins back.
  • How do we know the item dropped? A drop sensor at the bottom reports success or failure. If we trusted the motor alone, a stuck bag would still be charged.

These answers give us one promise that matters more than the others: money and stock always agree. An item leaves only when its payment is kept, and money is refunded only when no item left. Two smaller promises come with it. Change is worked out before the motor runs, from coins the machine really has. And a new way to pay or to make change doesn’t mean rewriting the states.

We leave out card processing (an extension), sending data to a backend, and pricing rules beyond a fixed price per item.

Go deeperFour more questions, and what each changes1 min
Question We assume If the answer were different
One item per purchase? Yes A basket adds a cart state; dispensing repeats per item
Who restocks, and how? An operator, with the machine out of service Restocking while selling needs locking per slot
Prices change? Set by the operator; read when chosen Remote price updates are another event on the machine’s queue
Several machines, one backend? One machine, standalone Telemetry and remote config become listeners and events

We’ll build the design in four stages. Each stage adds only the classes that a new requirement needs. In each diagram, the new parts are outlined, and the colors show what each class does (the key is under every diagram).

Interviewerminute 5

Start simple. A customer puts in a dollar and presses A1. What happens?

What they are testing

Whether you can get a working sale up in a few minutes without opening with four state classes. Starting with the State pattern before showing why it’s needed looks like a memorized answer.

Start with the smallest thing that works. We need coins, products, slots that hold products, and a machine that keeps a running balance. Money is stored as whole cents, never as decimals, so it never drifts.

Stage 1: the smallest machine that sells

1

*

returns

VendingMachine

-slots: Map<String, Slot>

-balance: int

+insert(Coin)

+select(code) : Optional<Sale>

Slot

+code

+product

-count

+available() : bool

«value»

Product

+name

+priceCents

«value»

Sale

+product

+changeCents

«enum»

Coin

NICKEL 5

DIME 10

QUARTER 25

DOLLAR 100

1

*

returns

VendingMachine

-slots: Map<String, Slot>

-balance: int

+insert(Coin)

+select(code) : Optional<Sale>

Slot

+code

+product

-count

+available() : bool

«value»

Product

+name

+priceCents

«value»

Sale

+product

+changeCents

«enum»

Coin

NICKEL 5

DIME 10

QUARTER 25

DOLLAR 100

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
VendingMachine.javavending_machine.pyVendingMachine.csvending_machine.govending-machine.ts
// The coins the machine accepts, each with its value. Money is integer cents, never a double.
enum Coin {
NICKEL(5), DIME(10), QUARTER(25), DOLLAR(100);
final int cents;
Coin(int cents) { this.cents = cents; }
}
// What is for sale. A value: two equal products are interchangeable.
record Product(String name, int priceCents) {}
// A slot is one coil in the machine: it has a keypad code, holds one product, and runs out.
class Slot {
final String code;
final Product product;
private int count;
Slot(String code, Product product, int count) {
this.code = code;
this.product = product;
this.count = count;
}
boolean available() { return count > 0; }
void take() { count--; }
}
// What a sale hands back: the product, and how much change is owed.
record Sale(Product product, int changeCents) {}
// The one class the keypad and coin slot talk to.
class VendingMachine {
private final Map<String, Slot> slots = new HashMap<>();
private int balance; // cents inserted so far
VendingMachine(List<Slot> slots) {
for (Slot s : slots) this.slots.put(s.code, s);
}
void insert(Coin c) { balance += c.cents; }
// The sale, or empty if it can't happen right now.
Optional<Sale> select(String code) {
Slot s = slots.get(code);
// flaw 1: every rule is an if in here, and each new feature (cancel, a motor that
// takes two seconds, maintenance mode) adds more ifs to every method. Stage 2.
if (s == null || !s.available() || balance < s.product.priceCents()) return Optional.empty();
s.take();
// flaw 2: change is a number. The machine can only hand back coins it holds,
// and it may not hold the right ones. Stage 3.
Sale sale = new Sale(s.product, balance - s.product.priceCents());
balance = 0;
return Optional.of(sale);
}
}

It sells. But it has two problems, and we leave them in on purpose. First, every rule is an if inside select. Each new feature (cancel, a motor that takes two seconds, a service mode) will add another if to every method. Second, change is just a number. A real machine can only hand back coins it actually has. Stage 2 fixes the first problem, and stage 3 fixes the second.

Stage 2: States, because behavior depends on the moment

Section titled “Stage 2: States, because behavior depends on the moment”
Interviewerminute 9

The motor takes two seconds. What if someone presses cancel, or puts in a coin, while it’s running?

What they are testing

Whether you can explain why a state machine is needed here (every event means something different depending on what the machine is doing), and choose classes over an enum for a reason. Drawing the states without the arrows between them is the weak version.

Let’s add a cancel button, a motor that runs for two seconds, and a service mode. Now every method needs the same checks. insert has to ask “am I dropping an item? Am I out of service?” So do select and cancel. Four events times four situations is sixteen cases, spread across four methods.

The State pattern turns this around. Each situation becomes a class, with one method for each event. So one class answers “what does a coin mean while an item is dropping?” and nothing else. The machine keeps all the data (the balance, the slots, the sale in progress) and simply passes each event to its current state.

Here are the states, and the events that move between them:

State: the machine

insert coin

insert coin / select fails

cancel (coins returned)

select succeeds

drop sensor reports

operator opens

operator closes

Idle

HasMoney

Dispensing

OutOfService

insert coin

insert coin / select fails

cancel (coins returned)

select succeeds

drop sensor reports

operator opens

operator closes

Idle

HasMoney

Dispensing

OutOfService

Scroll or pinch to zoom, drag to pan
  • Color key
  • State

Why is “dispensing” its own state, and not just a moment inside select? Because the motor works in the background. startDispense returns right away, and the drop sensor reports back later. While we wait, a new coin drops straight back out, and cancel is refused, because the bag is already moving.

Stage 2: states and a hardware port

1

*

current

VendingMachine

~balance

~pending: Slot

-state: MachineState

+insert(Coin)

+select(code)

+cancel()

+dispensed(ok)

+serviceMode(on) : bool

«interface»

MachineState

+insert(m, Coin)

+select(m, code)

+cancel(m)

+dispensed(m, ok)

Idle

HasMoney

Dispensing

OutOfService

«interface»

Hardware

+startDispense(code)

+returnCents(int)

+show(message)

Slot

1

*

current

VendingMachine

~balance

~pending: Slot

-state: MachineState

+insert(Coin)

+select(code)

+cancel()

+dispensed(ok)

+serviceMode(on) : bool

«interface»

MachineState

+insert(m, Coin)

+select(m, code)

+cancel(m)

+dispensed(m, ok)

Idle

HasMoney

Dispensing

OutOfService

«interface»

Hardware

+startDispense(code)

+returnCents(int)

+show(message)

Slot

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step

The machine talks to the motors, the coin return and the display through one interface, Hardware. In tests, a fake stands in for the real devices.

HasMoney.javahas_money.pyHasMoney.csy.gohas-money.ts
// The machine's outputs: a motor, a coin return and a display. An interface (a port), so the
// real hardware and a test's fakes plug in the same way.
interface Hardware {
void startDispense(String slotCode); // returns at once; the motor reports back via dispensed(ok)
void returnCoins(int cents); // stage 3 turns this into real coins
void show(String message);
}
// One method per thing that can happen to the machine. Each state decides what it means right
// now. The defaults are "not now", so each state only writes the events it handles.
interface MachineState {
default void insert(VendingMachine m, Coin c) { m.hardware.returnCoins(c.cents); } // drop it back out
default void select(VendingMachine m, String code) { m.hardware.show("Please wait"); }
default void cancel(VendingMachine m) {}
default void dispensed(VendingMachine m, boolean ok) {}
}
// Waiting for a customer. A coin starts a sale; a button just shows the price.
class Idle implements MachineState {
public void insert(VendingMachine m, Coin c) {
m.balance += c.cents;
m.setState(VendingMachine.HAS_MONEY);
m.hardware.show("Credit " + m.balance);
}
public void select(VendingMachine m, String code) {
Slot s = m.slots.get(code);
m.hardware.show(s == null ? "No such item" : "Price " + s.product.priceCents());
}
}
// Money is in. This is the only state where a sale can start, so every sale rule lives here.
class HasMoney implements MachineState {
public void insert(VendingMachine m, Coin c) {
m.balance += c.cents;
m.hardware.show("Credit " + m.balance);
}
public void select(VendingMachine m, String code) {
Slot s = m.slots.get(code);
if (s == null) { m.hardware.show("No such item"); return; }
if (!s.available()) { m.hardware.show("Sold out"); return; }
int price = s.product.priceCents();
if (m.balance < price) { m.hardware.show("Insert " + (price - m.balance) + " more"); return; }
m.pending = s;
// State first, then the motor: a fast motor may call dispensed() before
// startDispense even returns, and it must find the machine already DISPENSING.
m.setState(VendingMachine.DISPENSING);
m.hardware.startDispense(code);
}
public void cancel(VendingMachine m) {
m.hardware.returnCoins(m.balance);
m.balance = 0;
m.setState(VendingMachine.IDLE);
}
}
// The motor is running. Too late to cancel; coins drop straight back out.
// Only the motor's report moves the machine on.
class Dispensing implements MachineState {
public void dispensed(VendingMachine m, boolean ok) {
// flaw: assumes the motor worked. Stage 4 handles a jam.
m.pending.take();
m.hardware.returnCoins(m.balance - m.pending.product.priceCents());
m.balance = 0;
m.pending = null;
m.setState(VendingMachine.IDLE);
}
}
// The operator has the door open. Nothing sells; coins drop back out (the default).
class OutOfService implements MachineState {
public void select(VendingMachine m, String code) { m.hardware.show("Out of service"); }
}
VendingMachine.javavending_machine.pyVendingMachine.csvending_machine.govending-machine.ts
class VendingMachine {
// States hold no data, so one instance of each is shared. The data lives here.
static final MachineState IDLE = new Idle(), HAS_MONEY = new HasMoney(),
DISPENSING = new Dispensing(), OUT_OF_SERVICE = new OutOfService();
// Package-private: only the states read and change these.
final Hardware hardware;
final Map<String, Slot> slots = new HashMap<>();
int balance; // cents inserted for the current sale
Slot pending; // the slot being dispensed; set only while DISPENSING
private MachineState state = IDLE;
VendingMachine(List<Slot> slots, Hardware hardware) {
for (Slot s : slots) this.slots.put(s.code, s);
this.hardware = hardware;
}
// Every event goes to the current state. There is no "if state ==" anywhere in this class.
void insert(Coin c) { state.insert(this, c); }
void select(String code) { state.select(this, code); }
void cancel() { state.cancel(this); }
void dispensed(boolean ok) { state.dispensed(this, ok); }
// The operator can only take the machine out of service between customers.
boolean serviceMode(boolean on) {
if (on && state == IDLE) { state = OUT_OF_SERVICE; return true; }
if (!on && state == OUT_OF_SERVICE) { state = IDLE; return true; }
return false;
}
void setState(MachineState next) { state = next; }
MachineState state() { return state; }
}

Two details matter. First, the base MachineState answers “not now” to every event (return the coin, ask the customer to wait). So each state only writes the events it actually handles. Second, HasMoney switches to Dispensing before it starts the motor. A fast motor, or a fake one in a test, can report back from inside startDispense. If we switched state afterwards, that report would land in HasMoney and be ignored.

Why classes and not an enum with a switch? Here, every state does something different for every event. A class per state puts each answer in one place. Compare the parking lot ticket: its states only differed in which moves were allowed, so an enum with a table was the better fit there.

Go deeperWho changes the state: the machine or the states?1 min

In this design, the states do. Each state knows where it leads (HasMoney knows a successful select leads to Dispensing), so each change sits next to the behavior that causes it. The alternative is one central table in the machine. That shows the whole picture in one place, but splits each decision from its effect. For four states, keep the changes in the states and draw the state diagram once.

Interviewerminute 15

The machine owes 30 cents and has one quarter and three dimes left. What does it give back?

What they are testing

Whether you notice that “largest coin first” fails when coins are limited, and whether change is checked before the item drops. “Greedy is always right for US coins” is only true with an unlimited supply, and they know it.

The usual way to give change is called greedy: take the largest coin that fits, again and again. Let’s try it. We owe 30 cents. Greedy takes the quarter. Now it needs 5 more, but there’s no nickel. So it gives up, while three dimes were sitting right there.

Greedy is only right for US coins when the machine has unlimited coins of every kind. No real machine does. Here are three ways to make change from what’s actually inside:

Option How Right with limited coins? Cost
Greedy Largest coin first, as many as fit No Very fast
Backtracking (our choice) Largest first, but when stuck, put one big coin back and retry Yes Worst case tries every mix; tiny here
Dynamic programming Work out the fewest coins for every amount up to the change Yes, and fewest coins A table as big as change × coins held

Backtracking reads like greedy, so it’s easy to explain, and it’s always right. In our example, it takes the quarter, gets stuck, puts the quarter back, and tries dimes: three dimes. Most of the time, the greedy path works on the first try and it stops there. We put change making behind an interface, ChangeMaker, so we could switch to dynamic programming later if “fewest coins” ever matters. That’s the Strategy pattern.

Real coins bring a second idea: escrow. The customer’s coins are kept apart from the cash box until the sale finishes. If they cancel, they get those exact coins back: four quarters back, not a dollar coin from the cash box. When the sale finishes, the coins move into the cash box. They also count when making change, since they’ll be in the cash box a moment later.

Stage 3: escrow, cash box and change making

3

before dispensing

VendingMachine

~cash: CoinBox

~escrow: CoinBox

~pendingChange: CoinBox

~changeMaker: ChangeMaker

CoinBox

+add(Coin)

+addAll(CoinBox)

+removeAll(CoinBox)

+total() : int

+toList() : List<Coin>

«interface»

ChangeMaker

+make(cents, available) : Optional<CoinBox>

GreedyChange

BacktrackingChange

HasMoney

«interface»

Hardware

+returnCoins(List<Coin>)

3

before dispensing

VendingMachine

~cash: CoinBox

~escrow: CoinBox

~pendingChange: CoinBox

~changeMaker: ChangeMaker

CoinBox

+add(Coin)

+addAll(CoinBox)

+removeAll(CoinBox)

+total() : int

+toList() : List<Coin>

«interface»

ChangeMaker

+make(cents, available) : Optional<CoinBox>

GreedyChange

BacktrackingChange

HasMoney

«interface»

Hardware

+returnCoins(List<Coin>)

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
CoinBox.javabacktracking_change.pyBacktrackingChange.cse.gocoin-box.ts
// A bag of coins counted by kind. The escrow, the cash box and a handful of change are all one.
class CoinBox {
private final EnumMap<Coin, Integer> counts = new EnumMap<>(Coin.class);
void add(Coin c, int n) { counts.merge(c, n, Integer::sum); }
void add(Coin c) { add(c, 1); }
void addAll(CoinBox other) { other.counts.forEach(this::add); }
void removeAll(CoinBox other) { other.counts.forEach((c, n) -> add(c, -n)); }
int count(Coin c) { return counts.getOrDefault(c, 0); }
int total() { return counts.entrySet().stream().mapToInt(e -> e.getKey().cents * e.getValue()).sum(); }
void clear() { counts.clear(); }
List<Coin> toList() {
List<Coin> out = new ArrayList<>();
counts.forEach((c, n) -> out.addAll(Collections.nCopies(n, c)));
return out;
}
}
// How to pay out an amount from the coins on hand. Empty means it can't be done exactly.
interface ChangeMaker {
Optional<CoinBox> make(int cents, CoinBox available);
}
// Largest coin first. Always right with unlimited US coins, and wrong in a real machine:
// owed 30 with one quarter and three dimes, it takes the quarter, finds no nickel, and gives up.
class GreedyChange implements ChangeMaker {
public Optional<CoinBox> make(int cents, CoinBox available) {
CoinBox out = new CoinBox();
Coin[] big = Coin.values();
for (int i = big.length - 1; i >= 0; i--) {
int n = Math.min(cents / big[i].cents, available.count(big[i]));
out.add(big[i], n);
cents -= n * big[i].cents;
}
return cents == 0 ? Optional.of(out) : Optional.empty();
}
}
// Greedy first, but when the rest can't be made it backs off one big coin and tries again.
// Finds change whenever change exists. Worst case it tries every combination, which for
// four kinds of coin and a few dollars of change is a few thousand steps at most.
class BacktrackingChange implements ChangeMaker {
public Optional<CoinBox> make(int cents, CoinBox available) {
CoinBox out = new CoinBox();
return fill(Coin.values().length - 1, cents, available, out) ? Optional.of(out) : Optional.empty();
}
private boolean fill(int i, int cents, CoinBox available, CoinBox out) {
if (cents == 0) return true;
if (i < 0) return false; // ran out of coin kinds with money still owed
Coin c = Coin.values()[i];
for (int n = Math.min(cents / c.cents, available.count(c)); n >= 0; n--) {
if (fill(i - 1, cents - n * c.cents, available, out)) {
out.add(c, n);
return true;
}
}
return false;
}
}

HasMoney.select now works out the change before the motor starts. If the change can’t be made, the sale is refused with “Exact change only”, and the credit stays. The customer can pick something with a different price, or cancel.

VendingMachine.javahas_money.pyHasMoney.csy.gohas-money.ts
// The machine's outputs: a motor, a coin return and a display. An interface (a port), so the
// real hardware and a test's fakes plug in the same way.
interface Hardware {
void startDispense(String slotCode); // returns at once; the motor reports back via dispensed(ok)
void returnCoins(List<Coin> coins); // the very coins, not just an amount
void show(String message);
}
// One method per thing that can happen to the machine. Each state decides what it means right
// now. The defaults are "not now": a coin drops back out, and a button asks the customer to wait.
interface MachineState {
default void insert(VendingMachine m, Coin c) { m.hardware.returnCoins(List.of(c)); }
default void select(VendingMachine m, String code) { m.hardware.show("Please wait"); }
default void cancel(VendingMachine m) {}
default void dispensed(VendingMachine m, boolean ok) {}
}
// Waiting for a customer. A button press just shows the price.
class Idle implements MachineState {
public void insert(VendingMachine m, Coin c) {
m.escrow.add(c); // held apart from the cash box until the sale completes
m.setState(VendingMachine.HAS_MONEY);
m.hardware.show("Credit " + m.escrow.total());
}
public void select(VendingMachine m, String code) {
Slot s = m.slots.get(code);
m.hardware.show(s == null ? "No such item" : "Price " + s.product.priceCents());
}
}
// Holding the customer's coins. A selection checks stock, credit and change before anything moves.
class HasMoney implements MachineState {
public void insert(VendingMachine m, Coin c) {
m.escrow.add(c);
m.hardware.show("Credit " + m.escrow.total());
}
public void select(VendingMachine m, String code) {
Slot s = m.slots.get(code);
if (s == null) { m.hardware.show("No such item"); return; }
if (!s.available()) { m.hardware.show("Sold out"); return; }
int credit = m.escrow.total(), price = s.product.priceCents();
if (credit < price) { m.hardware.show("Insert " + (price - credit) + " more"); return; }
// Work out the change before anything moves. The customer's own coins count, since
// they go into the cash box the moment the sale completes.
CoinBox available = new CoinBox();
available.addAll(m.cash);
available.addAll(m.escrow);
Optional<CoinBox> change = m.changeMaker.make(credit - price, available);
if (change.isEmpty()) { m.hardware.show("Exact change only"); return; } // credit kept; pick again or cancel
m.pending = s;
m.pendingChange = change.get();
m.setState(VendingMachine.DISPENSING);
m.hardware.startDispense(code);
}
// Cancel returns the very coins the customer put in, not "the same amount".
public void cancel(VendingMachine m) {
m.hardware.returnCoins(m.escrow.toList());
m.escrow.clear();
m.setState(VendingMachine.IDLE);
}
}
class Dispensing implements MachineState {
public void dispensed(VendingMachine m, boolean ok) {
// flaw: still assumes the motor worked. Stage 4 handles a jam.
m.pending.take();
m.cash.addAll(m.escrow); // the sale is done: escrow becomes the machine's money
m.cash.removeAll(m.pendingChange);
m.hardware.returnCoins(m.pendingChange.toList());
m.escrow.clear();
m.pending = null;
m.pendingChange = null;
m.setState(VendingMachine.IDLE);
}
}
// The keypad and coin slot call this; it hands each event to the current state. The states hold
// no data of their own, so one shared object per state is enough.
class VendingMachine {
static final MachineState IDLE = new Idle(), HAS_MONEY = new HasMoney(),
DISPENSING = new Dispensing(), OUT_OF_SERVICE = new OutOfService();
final Hardware hardware;
final ChangeMaker changeMaker; // injected: greedy, backtracking, fewest-coins
final Map<String, Slot> slots = new HashMap<>();
final CoinBox cash = new CoinBox(); // the machine's money, used for change
final CoinBox escrow = new CoinBox(); // this customer's coins, returned as-is on cancel
Slot pending;
CoinBox pendingChange; // decided before the motor starts
private MachineState state = IDLE;
VendingMachine(List<Slot> slots, CoinBox float_, ChangeMaker changeMaker, Hardware hardware) {
for (Slot s : slots) this.slots.put(s.code, s);
this.cash.addAll(float_); // the coins the operator loads for change
this.changeMaker = changeMaker;
this.hardware = hardware;
}
void insert(Coin c) { state.insert(this, c); }
void select(String code) { state.select(this, code); }
void cancel() { state.cancel(this); }
void dispensed(boolean ok) { state.dispensed(this, ok); }
// The operator's key. Only an idle machine switches off, so no customer's sale is cut in half.
boolean serviceMode(boolean on) {
if (on && state == IDLE) { state = OUT_OF_SERVICE; return true; }
if (!on && state == OUT_OF_SERVICE) { state = IDLE; return true; }
return false;
}
void setState(MachineState next) { state = next; }
MachineState state() { return state; }
}

The change is set aside in pendingChange, but not taken out of the cash box until the sale finishes. That’s only safe because nothing else can take coins from the cash box in between. The “one event at a time” rule later on is what guarantees it.

Go deeperWhy not dynamic programming from the start?1 min

It’s the textbook answer, and it also finds the fewest coins. But it’s harder to explain in an interview, and “fewest coins” isn’t a requirement: being right is. Backtracking in largest-first order gives the same answer as greedy whenever greedy works, and finds an answer when greedy doesn’t. Both sit behind ChangeMaker, so switching later is one line where the machine is created.

Interviewerminute 21

The motor turns, but the bag catches on the coil. Who has the money now?

What they are testing

Whether you treat a sale as a small transaction (reserve, act, then finish or undo) and finish only after the item is confirmed. Lowering the stock and keeping the coins inside select, before the drop, is the bug they’re looking for.

Think of a sale as a small transaction, in three steps:

  1. Reserve. In select, check and set aside everything: the item is in stock, the credit is enough, and the change can be made.
  2. Act. Run the motor.
  3. Decide. Only the drop sensor’s report decides what happens next. If the item dropped, we finish the sale: stock goes down by one, the escrow goes into the cash box, and the change comes out. If it didn’t, we roll back: the escrow goes back to the customer, and nothing else changes.

Because nothing was finished before the sensor reported, rolling back is just returning the escrow.

Sequence: a purchase, committed only after the drop
HardwareChangeMakerHasMoney / DispensingVendingMachineCustomerHardwareChangeMakerHasMoney / DispensingVendingMachineCustomerstate = Dispensing, then start the motorcommit: stock - 1, escrow into cash, change outroll back: slot jammedalt[item dropped][jammed]insert(DOLLAR)insert: escrow += DOLLARselect(A1)selectmake(35, cash + escrow)DIME + QUARTERstartDispense(A1)dispensed(ok) from the drop sensorreturnCoins(DIME, QUARTER)returnCoins(escrow)HardwareChangeMakerHasMoney / DispensingVendingMachineCustomerHardwareChangeMakerHasMoney / DispensingVendingMachineCustomerstate = Dispensing, then start the motorcommit: stock - 1, escrow into cash, change outroll back: slot jammedalt[item dropped][jammed]insert(DOLLAR)insert: escrow += DOLLARselect(A1)selectmake(35, cash + escrow)DIME + QUARTERstartDispense(A1)dispensed(ok) from the drop sensorreturnCoins(DIME, QUARTER)returnCoins(escrow)
Scroll or pinch to zoom, drag to pan
  • Color key
  • Interface
  • Implementation
  • Facade
Slot.javaslot.pyDispensing.csslot.goslot.ts
// One coil of one product, known by its keypad code, like "B4".
class Slot {
final String code;
final Product product;
private int count;
private boolean jammed;
Slot(String code, Product product, int count) {
this.code = code;
this.product = product;
this.count = count;
}
// A jammed coil is as good as empty until the operator clears it.
boolean available() { return count > 0 && !jammed; }
void take() { count--; }
void jam() { jammed = true; }
void clearJam() { jammed = false; }
}
// The motor is turning. Coins and buttons get the "not now" defaults until the drop sensor
// reports back.
class Dispensing implements MachineState {
// ok comes from the drop sensor at the bottom of the machine, not from the motor:
// a motor can turn a full circle and still leave the bag hanging on the coil.
public void dispensed(VendingMachine m, boolean ok) {
if (ok) {
// Commit: stock down, the customer's coins into the cash box, change out.
m.pending.take();
m.cash.addAll(m.escrow);
m.cash.removeAll(m.pendingChange);
m.hardware.returnCoins(m.pendingChange.toList());
} else {
// Nothing was sold, so nothing is committed. The customer gets their own coins
// back, and the coil is marked so the next customer isn't sold the same jam.
m.pending.jam();
m.hardware.returnCoins(m.escrow.toList());
m.hardware.show("Sorry, could not dispense. Coins returned.");
}
m.escrow.clear();
m.pending = null;
m.pendingChange = null;
m.setState(VendingMachine.IDLE);
}
}

A jammed slot marks itself as unavailable. So the next customer sees “Sold out” instead of paying for the same stuck bag. The operator clears it.

Why trust the drop sensor, and not the motor? Because the customer should only pay for an item that actually fell. Real machines use an infrared beam across the delivery bin for exactly this. The cost is one more device, with its own way to fail: a dirty sensor that misses a drop gives away free items. That’s why each slot is also counted, so the operator can check the numbers.

Go deeperWhat if the power fails while dispensing?1 min

Our in-memory design loses the sale in progress. On restart, the machine doesn’t know whether the item dropped. Real machines save the pending sale to memory that survives power loss, before they start the motor. On restart, they treat an unfinished sale as failed and refund the escrow. The coins are still physically in it. Losing one item is cheaper than charging someone for nothing.

In code, this is one more method on the machine, recover(), called once at start-up before the event queue begins. It reads the saved sale, if there is one, refunds its escrow, marks the slot for the operator to check, and starts in Idle. Because it runs before any event, it needs no locking. A test can check it by saving a pending sale, building a new machine, and confirming the refund.

This is enough for a mid-level answer. The states have clear moves between them, a sale is refused without enough credit or stock, and change is returned. The rest of the page is what makes it a senior answer.

Here is everything together. Each class is here because a requirement needed it.

Vending machine: full class diagram

owns

1

*

3

current

VendingMachine

~slots: Map<String, Slot>

~cash: CoinBox

~escrow: CoinBox

~pending: Slot

~pendingChange: CoinBox

-state: MachineState

+insert(Coin)

+select(code)

+cancel()

+dispensed(ok)

+serviceMode(on) : bool

MachineLoop

-events: BlockingQueue<Event>

+post(Event)

+run()

«interface»

MachineState

+insert(m, Coin)

+select(m, code)

+cancel(m)

+dispensed(m, ok)

Idle

HasMoney

Dispensing

OutOfService

«interface»

Hardware

+startDispense(code)

+returnCoins(List<Coin>)

+show(message)

«interface»

ChangeMaker

+make(cents, available) : Optional<CoinBox>

BacktrackingChange

GreedyChange

Slot

+code

+product

-count

-jammed

+available() : bool

CoinBox

+add(Coin)

+total() : int

+toList() : List<Coin>

«value»

Product

+name

+priceCents

«value»

Event

«enum»

Coin

NICKEL

DIME

QUARTER

DOLLAR

owns

1

*

3

current

VendingMachine

~slots: Map<String, Slot>

~cash: CoinBox

~escrow: CoinBox

~pending: Slot

~pendingChange: CoinBox

-state: MachineState

+insert(Coin)

+select(code)

+cancel()

+dispensed(ok)

+serviceMode(on) : bool

MachineLoop

-events: BlockingQueue<Event>

+post(Event)

+run()

«interface»

MachineState

+insert(m, Coin)

+select(m, code)

+cancel(m)

+dispensed(m, ok)

Idle

HasMoney

Dispensing

OutOfService

«interface»

Hardware

+startDispense(code)

+returnCoins(List<Coin>)

+show(message)

«interface»

ChangeMaker

+make(cents, available) : Optional<CoinBox>

BacktrackingChange

GreedyChange

Slot

+code

+product

-count

-jammed

+available() : bool

CoinBox

+add(Coin)

+total() : int

+toList() : List<Coin>

«value»

Product

+name

+priceCents

«value»

Event

«enum»

Coin

NICKEL

DIME

QUARTER

DOLLAR

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
vending-machine· read only
AllInOne.java
AllInOne.java
// The complete design, built from every stage on this page.
import java.util.*;
import java.util.concurrent.*;
// The coins the machine accepts, each with its value. Money is integer cents, never a double.
enum Coin {
NICKEL(5), DIME(10), QUARTER(25), DOLLAR(100);
final int cents;
Coin(int cents) { this.cents = cents; }
}
// What is for sale. A value: two equal products are interchangeable.
record Product(String name, int priceCents) {}
// One coil of one product, known by its keypad code, like "B4".
class Slot {
final String code;
final Product product;
private int count;
private boolean jammed;
Slot(String code, Product product, int count) {
this.code = code;
this.product = product;
this.count = count;
}
// A jammed coil is as good as empty until the operator clears it.
boolean available() { return count > 0 && !jammed; }
void take() { count--; }
void jam() { jammed = true; }
void clearJam() { jammed = false; }
}
// The keypad and coin slot call this; it hands each event to the current state. The states hold
// no data of their own, so one shared object per state is enough.
class VendingMachine {
static final MachineState IDLE = new Idle(), HAS_MONEY = new HasMoney(),
DISPENSING = new Dispensing(), OUT_OF_SERVICE = new OutOfService();
final Hardware hardware;
final ChangeMaker changeMaker; // injected: greedy, backtracking, fewest-coins
final Map<String, Slot> slots = new HashMap<>();
final CoinBox cash = new CoinBox(); // the machine's money, used for change
final CoinBox escrow = new CoinBox(); // this customer's coins, returned as-is on cancel
Slot pending;
CoinBox pendingChange; // decided before the motor starts
private MachineState state = IDLE;
VendingMachine(List<Slot> slots, CoinBox float_, ChangeMaker changeMaker, Hardware hardware) {
for (Slot s : slots) this.slots.put(s.code, s);
this.cash.addAll(float_); // the coins the operator loads for change
this.changeMaker = changeMaker;
this.hardware = hardware;
}
void insert(Coin c) { state.insert(this, c); }
void select(String code) { state.select(this, code); }
void cancel() { state.cancel(this); }
void dispensed(boolean ok) { state.dispensed(this, ok); }
// The operator's key. Only an idle machine switches off, so no customer's sale is cut in half.
boolean serviceMode(boolean on) {
if (on && state == IDLE) { state = OUT_OF_SERVICE; return true; }
if (!on && state == OUT_OF_SERVICE) { state = IDLE; return true; }
return false;
}
void setState(MachineState next) { state = next; }
MachineState state() { return state; }
}
// The machine's outputs: a motor, a coin return and a display. An interface (a port), so the
// real hardware and a test's fakes plug in the same way.
interface Hardware {
void startDispense(String slotCode); // returns at once; the motor reports back via dispensed(ok)
void returnCoins(List<Coin> coins); // the very coins, not just an amount
void show(String message);
}
// One method per thing that can happen to the machine. Each state decides what it means right
// now. The defaults are "not now": a coin drops back out, and a button asks the customer to wait.
interface MachineState {
default void insert(VendingMachine m, Coin c) { m.hardware.returnCoins(List.of(c)); }
default void select(VendingMachine m, String code) { m.hardware.show("Please wait"); }
default void cancel(VendingMachine m) {}
default void dispensed(VendingMachine m, boolean ok) {}
}
// Waiting for a customer. A button press just shows the price.
class Idle implements MachineState {
public void insert(VendingMachine m, Coin c) {
m.escrow.add(c); // held apart from the cash box until the sale completes
m.setState(VendingMachine.HAS_MONEY);
m.hardware.show("Credit " + m.escrow.total());
}
public void select(VendingMachine m, String code) {
Slot s = m.slots.get(code);
m.hardware.show(s == null ? "No such item" : "Price " + s.product.priceCents());
}
}
// Holding the customer's coins. A selection checks stock, credit and change before anything moves.
class HasMoney implements MachineState {
public void insert(VendingMachine m, Coin c) {
m.escrow.add(c);
m.hardware.show("Credit " + m.escrow.total());
}
public void select(VendingMachine m, String code) {
Slot s = m.slots.get(code);
if (s == null) { m.hardware.show("No such item"); return; }
if (!s.available()) { m.hardware.show("Sold out"); return; }
int credit = m.escrow.total(), price = s.product.priceCents();
if (credit < price) { m.hardware.show("Insert " + (price - credit) + " more"); return; }
// Work out the change before anything moves. The customer's own coins count, since
// they go into the cash box the moment the sale completes.
CoinBox available = new CoinBox();
available.addAll(m.cash);
available.addAll(m.escrow);
Optional<CoinBox> change = m.changeMaker.make(credit - price, available);
if (change.isEmpty()) { m.hardware.show("Exact change only"); return; } // credit kept; pick again or cancel
m.pending = s;
m.pendingChange = change.get();
m.setState(VendingMachine.DISPENSING);
m.hardware.startDispense(code);
}
// Cancel returns the very coins the customer put in, not "the same amount".
public void cancel(VendingMachine m) {
m.hardware.returnCoins(m.escrow.toList());
m.escrow.clear();
m.setState(VendingMachine.IDLE);
}
}
// The motor is turning. Coins and buttons get the "not now" defaults until the drop sensor
// reports back.
class Dispensing implements MachineState {
// ok comes from the drop sensor at the bottom of the machine, not from the motor:
// a motor can turn a full circle and still leave the bag hanging on the coil.
public void dispensed(VendingMachine m, boolean ok) {
if (ok) {
// Commit: stock down, the customer's coins into the cash box, change out.
m.pending.take();
m.cash.addAll(m.escrow);
m.cash.removeAll(m.pendingChange);
m.hardware.returnCoins(m.pendingChange.toList());
} else {
// Nothing was sold, so nothing is committed. The customer gets their own coins
// back, and the coil is marked so the next customer isn't sold the same jam.
m.pending.jam();
m.hardware.returnCoins(m.escrow.toList());
m.hardware.show("Sorry, could not dispense. Coins returned.");
}
m.escrow.clear();
m.pending = null;
m.pendingChange = null;
m.setState(VendingMachine.IDLE);
}
}
// The operator has the door open. Nothing sells; coins drop back out (the default).
class OutOfService implements MachineState {
public void select(VendingMachine m, String code) { m.hardware.show("Out of service"); }
}
// A bag of coins counted by kind. The escrow, the cash box and a handful of change are all one.
class CoinBox {
private final EnumMap<Coin, Integer> counts = new EnumMap<>(Coin.class);
void add(Coin c, int n) { counts.merge(c, n, Integer::sum); }
void add(Coin c) { add(c, 1); }
void addAll(CoinBox other) { other.counts.forEach(this::add); }
void removeAll(CoinBox other) { other.counts.forEach((c, n) -> add(c, -n)); }
int count(Coin c) { return counts.getOrDefault(c, 0); }
int total() { return counts.entrySet().stream().mapToInt(e -> e.getKey().cents * e.getValue()).sum(); }
void clear() { counts.clear(); }
List<Coin> toList() {
List<Coin> out = new ArrayList<>();
counts.forEach((c, n) -> out.addAll(Collections.nCopies(n, c)));
return out;
}
}
// How to pay out an amount from the coins on hand. Empty means it can't be done exactly.
interface ChangeMaker {
Optional<CoinBox> make(int cents, CoinBox available);
}
// Largest coin first. Always right with unlimited US coins, and wrong in a real machine:
// owed 30 with one quarter and three dimes, it takes the quarter, finds no nickel, and gives up.
class GreedyChange implements ChangeMaker {
public Optional<CoinBox> make(int cents, CoinBox available) {
CoinBox out = new CoinBox();
Coin[] big = Coin.values();
for (int i = big.length - 1; i >= 0; i--) {
int n = Math.min(cents / big[i].cents, available.count(big[i]));
out.add(big[i], n);
cents -= n * big[i].cents;
}
return cents == 0 ? Optional.of(out) : Optional.empty();
}
}
// Greedy first, but when the rest can't be made it backs off one big coin and tries again.
// Finds change whenever change exists. Worst case it tries every combination, which for
// four kinds of coin and a few dollars of change is a few thousand steps at most.
class BacktrackingChange implements ChangeMaker {
public Optional<CoinBox> make(int cents, CoinBox available) {
CoinBox out = new CoinBox();
return fill(Coin.values().length - 1, cents, available, out) ? Optional.of(out) : Optional.empty();
}
private boolean fill(int i, int cents, CoinBox available, CoinBox out) {
if (cents == 0) return true;
if (i < 0) return false; // ran out of coin kinds with money still owed
Coin c = Coin.values()[i];
for (int n = Math.min(cents / c.cents, available.count(c)); n >= 0; n--) {
if (fill(i - 1, cents - n * c.cents, available, out)) {
out.add(c, n);
return true;
}
}
return false;
}
}
// Everything that can happen to the machine, as data. Hardware threads create these;
// only the loop thread acts on them.
sealed interface Event permits CoinInserted, Selected, Cancelled, Dispensed {}
record CoinInserted(Coin coin) implements Event {}
record Selected(String code) implements Event {}
record Cancelled() implements Event {}
record Dispensed(boolean ok) implements Event {}
// One thread owns the machine. The coin acceptor, the keypad and the drop sensor each run on
// their own thread and only post events, so two events are never handled at once and the
// states need no locks at all.
class MachineLoop implements Runnable {
private final BlockingQueue<Event> events = new LinkedBlockingQueue<>();
private final VendingMachine machine;
MachineLoop(VendingMachine machine) { this.machine = machine; }
// Safe from any thread, and never blocks the caller: the coin acceptor can't wait.
void post(Event e) { events.add(e); }
// The loop: take the next event, apply it, repeat. One event at a time is the whole design.
public void run() {
while (!Thread.currentThread().isInterrupted()) {
Event e;
try {
e = events.take(); // handled strictly in arrival order
} catch (InterruptedException stop) {
return;
}
try {
switch (e) {
case CoinInserted c -> machine.insert(c.coin());
case Selected s -> machine.select(s.code());
case Cancelled c -> machine.cancel();
case Dispensed d -> machine.dispensed(d.ok());
}
} catch (RuntimeException bug) {
// One bad event must not stop the machine; log it and take the next.
System.err.println("event " + e + " failed: " + bug);
}
}
}
}
Read onlyJava 21Every file builds in CI21 files284 lines
all_in_one.py
all_in_one.py
Read onlyPython 3.12Every file builds in CI17 files275 lines
AllInOne.cs
AllInOne.cs
Read onlyC# · .NET 10Every file builds in CI21 files279 lines
all_in_one.go
all_in_one.go
Read onlyGo 1.24Every file builds in CI20 files328 lines
all-in-one.ts
all-in-one.ts
Read onlyTypeScript, strictEvery file builds in CI17 files303 lines

Three ideas hold it together:

  • The states hold no data. All the data lives in the machine, so one object of each state is shared, and changing state is one assignment. Each state is the State pattern.
  • Money moves at exactly two moments. Into escrow when a coin goes in, and out of escrow when a sale finishes or rolls back. Nothing else touches the cash box.
  • The machine only talks to devices through the Hardware interface. So the whole design runs in a test with a fake. An interface like this, standing in front of outside devices, is called a port (it’s the Adapter pattern). Change making is behind ChangeMaker (Strategy). And to the devices, the machine is a simple front door with four events (a facade).

The design works when events come one after another. But the coin slot, the keypad and the drop sensor are separate devices. What happens when their events overlap?

Interviewerminute 28

The coin acceptor, the keypad and the drop sensor are separate devices with their own threads. What can go wrong?

What they are testing

Whether you find a real overlap (cancel and select landing together) and fix it for the whole machine, not one method at a time. Making each method synchronized works. Explaining why an event queue fits hardware better is the stronger answer.

Three threads now call the machine. Here is the dangerous case. The customer presses a product and the cancel lever at almost the same moment, and the two events arrive on different threads.

  1. Both threads see the state as HasMoney.
  2. The select thread sets aside change and starts the motor.
  3. The cancel thread returns the escrow and empties it.
  4. The item drops. The sale finishes: it moves an empty escrow into the cash box, and still pays out the change.

The customer walks away with the item, their own coins, and change on top. There are smaller versions of the same problem, like two coins arriving together and one being lost.

Our promise, “money and stock always agree”, covers the state, the escrow, the cash box and the slot together. So each event must be handled completely before the next one starts.

The clearly correct fix is one lock on the machine. Every public method takes the same lock. That works. But while one event runs, the device threads have to wait.

A better fit for hardware is one event queue. The devices don’t call the machine directly. They put events into a queue, which never makes them wait. One thread takes events from the queue, in order, and handles each one completely. This is how most embedded controllers are written.

Option How Fits hardware?
1. A lock on the machine Every public method takes the same lock Works; device threads wait while another event runs
2. One event queue (our choice) Devices add events to a queue; one thread handles them in order Yes: devices never wait, and the order is clear
3. Atomic state field Compare-and-set the state on each change No: the state alone doesn’t protect the money
MachineLoop.javamachine_loop.pyMachineLoop.csvending_machine.gomachine-loop.ts
// Everything that can happen to the machine, as data. Hardware threads create these;
// only the loop thread acts on them.
sealed interface Event permits CoinInserted, Selected, Cancelled, Dispensed {}
record CoinInserted(Coin coin) implements Event {}
record Selected(String code) implements Event {}
record Cancelled() implements Event {}
record Dispensed(boolean ok) implements Event {}
// One thread owns the machine. The coin acceptor, the keypad and the drop sensor each run on
// their own thread and only post events, so two events are never handled at once and the
// states need no locks at all.
class MachineLoop implements Runnable {
private final BlockingQueue<Event> events = new LinkedBlockingQueue<>();
private final VendingMachine machine;
MachineLoop(VendingMachine machine) { this.machine = machine; }
// Safe from any thread, and never blocks the caller: the coin acceptor can't wait.
void post(Event e) { events.add(e); }
// The loop: take the next event, apply it, repeat. One event at a time is the whole design.
public void run() {
while (!Thread.currentThread().isInterrupted()) {
Event e;
try {
e = events.take(); // handled strictly in arrival order
} catch (InterruptedException stop) {
return;
}
try {
switch (e) {
case CoinInserted c -> machine.insert(c.coin());
case Selected s -> machine.select(s.code());
case Cancelled c -> machine.cancel();
case Dispensed d -> machine.dispensed(d.ok());
}
} catch (RuntimeException bug) {
// One bad event must not stop the machine; log it and take the next.
System.err.println("event " + e + " failed: " + bug);
}
}
}
}

Why is this correct? Only the queue’s thread ever touches the machine. So no two events overlap, and each one sees exactly what the previous one left. The queue is the only shared object, and it’s built for many writers and one reader.

Now replay the dangerous case. Select and cancel are decided by which one reached the queue first. If select came first, cancel arrives while the machine is in Dispensing, and it’s refused. If cancel came first, the coins go back, and select arrives in Idle, where it only shows the price. Either way, the customer gets exactly one outcome.

What does it cost? A slow handler delays every event behind it. So handlers must never wait on anything. That’s exactly why the motor is started in the background and reports back as an event. And a handler that throws an error must be caught, or the machine stops responding.

Go deeperHow each language builds the queue1 min

Java uses a BlockingQueue and one thread. Python uses a queue.Queue. C# uses a Channel with a single reader. Go uses a channel and one goroutine, which is the natural Go shape for this problem.

TypeScript in Node has no threads, so there’s no race. But one call can still run inside another. A fast motor that reports back from inside startDispense would run dispensed in the middle of select. The small queue in MachineLoop makes that event wait its turn.

Go deeperWhat if the queue fills up?1 min

It has no limit here, because a person can only press buttons and feed coins a few times a second. If a device went wrong (a stuck sensor firing thousands of events), a queue with a limit that drops and logs the extra events would protect the machine. Coins are the one event worth never dropping. If the machine can’t accept a coin, the coin slot should physically reject it.

The Hardware interface makes every test a plain unit test. A fake records what was dropped, shown and returned. The test plays the drop sensor by calling dispensed. Name each test after the promise it protects. The ones that matter are about money:

  • Greedy fails on one quarter and three dimes. Backtracking returns three dimes.
  • When change can’t be made, nothing is dropped and the credit is kept.
  • Cancel returns the customer’s own coins.
  • Cancel is refused once the motor is running, and change is paid only after the drop.
  • A jam refunds the escrow, and the slot then shows as sold out.
  • Coins sent from many threads at once are never lost.

The last test sends 800 nickels from 8 threads, then cancels. Exactly 4,000 cents must come back. With the queue removed, and the threads calling the machine directly, it loses coins on most runs. A race test can pass by luck, so pair it with the reasoning above.

Go deeperThe tests in five languages2 min
FakeHardware.javafake_hardware.pyFakeHardware.csfake_hardware.gofake-hardware.ts
// Records what the machine did instead of moving motors. The drop sensor is the test itself.
static class FakeHardware implements Hardware {
final List<String> dispensed = new ArrayList<>(), shown = new ArrayList<>();
final List<Coin> returned = Collections.synchronizedList(new ArrayList<>());
public void startDispense(String code) { dispensed.add(code); }
public void returnCoins(List<Coin> coins) { returned.addAll(coins); }
public void show(String message) { shown.add(message); }
}
static CoinBox coins(Coin... cs) {
CoinBox b = new CoinBox();
for (Coin c : cs) b.add(c);
return b;
}
FakeHardware hw = new FakeHardware();
VendingMachine machine(CoinBox float_) {
// One slot: chips at 65 cents, two bags.
return new VendingMachine(List.of(new Slot("A1", new Product("Chips", 65), 2)),
float_, new BacktrackingChange(), hw);
}
@Test
void greedyGivesUpWhereBacktrackingFindsChange() {
CoinBox box = coins(Coin.QUARTER, Coin.DIME, Coin.DIME, Coin.DIME); // no nickels
assertTrue(new GreedyChange().make(30, box).isEmpty()); // takes the quarter, stuck at 5
assertEquals(List.of(Coin.DIME, Coin.DIME, Coin.DIME),
new BacktrackingChange().make(30, box).orElseThrow().toList());
}
@Test
void noChangeMeansNoSaleAndTheCreditIsKept() {
VendingMachine m = machine(new CoinBox()); // empty cash box
m.insert(Coin.DOLLAR);
m.select("A1"); // owes 35, holds nothing to pay it with
assertEquals("Exact change only", hw.shown.getLast());
assertTrue(hw.dispensed.isEmpty());
m.cancel();
assertEquals(List.of(Coin.DOLLAR), hw.returned); // the very coin they put in
}
@Test
void cancelIsRefusedOnceTheMotorRuns() {
VendingMachine m = machine(coins(Coin.DIME, Coin.QUARTER));
m.insert(Coin.DOLLAR);
m.select("A1");
m.cancel(); // too late: the bag is already moving
assertTrue(hw.returned.isEmpty());
m.dispensed(true);
assertEquals(35, coins(hw.returned.toArray(Coin[]::new)).total()); // change only after the drop
}
@Test
void aJamRefundsTheCustomerAndRetiresTheSlot() {
VendingMachine m = machine(coins(Coin.DIME, Coin.QUARTER));
m.insert(Coin.QUARTER); m.insert(Coin.QUARTER); m.insert(Coin.QUARTER);
m.select("A1");
m.dispensed(false);
assertEquals(List.of(Coin.QUARTER, Coin.QUARTER, Coin.QUARTER), hw.returned);
m.insert(Coin.DOLLAR);
m.select("A1");
assertEquals("Sold out", hw.shown.getLast()); // jammed counts as empty
}
@Test
void coinsPostedFromManyThreadsAreNeverLost() throws Exception {
VendingMachine m = machine(new CoinBox());
MachineLoop loop = new MachineLoop(m);
Thread owner = new Thread(loop);
owner.start();
ExecutorService acceptors = Executors.newFixedThreadPool(8);
for (int i = 0; i < 800; i++) acceptors.submit(() -> loop.post(new CoinInserted(Coin.NICKEL)));
acceptors.shutdown();
acceptors.awaitTermination(5, TimeUnit.SECONDS);
loop.post(new Cancelled()); // returns everything inserted so far
while (hw.returned.size() < 800) Thread.onSpinWait();
assertEquals(800 * 5, coins(hw.returned.toArray(Coin[]::new)).total());
owner.interrupt();
}
Go deeperEvery edge case, and where it is handled2 min
Case What happens Where
Select with no money Shows the price Idle.select
Not enough credit “Insert 15 more”; stays in HasMoney HasMoney.select
Sold out, or jammed “Sold out”; credit kept Slot.available
Can’t make change “Exact change only”; credit kept HasMoney.select
Cancel before dispensing The same coins returned HasMoney.cancel, escrow
Cancel while dispensing Refused (default) Dispensing
Coin while dispensing or out of service Coin drops straight back out MachineState default
Item doesn’t drop Escrow refunded, slot marked jammed Dispensing.dispensed
Motor reports before startDispense returns Already in Dispensing, handled normally state set before motor starts
Drop report while not dispensing Ignored (default) MachineState default
Operator opens during a sale Refused; service mode only from Idle VendingMachine.serviceMode
Handler throws Logged; the queue moves to the next event MachineLoop
Decision We chose We gave up
Behavior by situation State pattern, empty states, data in the machine A switch would be shorter with fewer states
Who changes state Each state knows where it leads The whole graph in one place in code
Change Backtracking behind ChangeMaker, decided before dispensing Fewest coins (one class away)
Customer’s coins Escrow, returned as they are, kept only on success A little bookkeeping
When a sale counts The drop sensor’s report Relies on one more sensor
Hardware One Hardware interface, faked in tests Smaller, separate interfaces
Concurrency One event queue Handlers must never wait
Go deeperGenuinely different designs, and when to pick them2 min

An enum and a table. If the states only differed in which events are allowed, a table of (state, event) → next state would be smaller and show the whole picture at once. It gets awkward here, because each cell needs real behavior: return a coin, work out change, start a motor.

A state machine library. Tools like Spring Statemachine, XState, or the SCXML standard describe states, events and rules as data. They also add states inside states. For example, a “Selling” state could contain HasMoney and Dispensing, so cancel is handled once. Worth it with dozens of states, or when people who don’t code need to read the picture. Too much for five.

One class that asks questions. Some answers make VendingMachine a single class with methods like canAcceptCoin() and no state objects. That’s fine for two states. The ifs come back as soon as a third appears.

  • 0 to 4. How people pay, change from real coins, cancel, the drop sensor.
  • 4 to 7. The promises. Name “money and stock always agree” as the hard one.
  • 7 to 27. Stages 1 to 4. Draw the state diagram. Code HasMoney.select and the change maker.
  • 27 to 30. Walk through the full diagram: empty states, money moves at two moments, the port.
  • 30 to 38. Devices at the same time: the cancel-versus-select race, then the event queue.
  • 38 to 45. Card payments or alerts, then your own questions.

If time is short, describe escrow in a sentence and skip the service mode. Never skip the change discussion. It’s the part most people get wrong.

Interviewerminute 37

We want card payments, and the operator wants an alert when a slot jams or change runs low.

What they are testing

Whether each change lands in one place. A card that needs edits to every state, or alerts wired into Dispensing by name, means the design didn’t absorb it.

Most extensions fit into one place:

Extension What changes Touches the states?
Card payments A Payment interface (coins, card); an Authorizing state; the bank replies as an event One new state
Operator alerts A listener list on the machine; jams, sold out and low change send events One call each
Fewest-coins change A DynamicProgrammingChange behind ChangeMaker No
Remote price updates A PriceChanged event on the queue, applied between customers No
Several items per sale A Cart state; dispensing repeats per item; a partial failure refunds the rest Yes, a new flow
Telemetry Another listener that sends batches of events to a backend No
Go deeperAdding card payments, step by step3 min

A card payment takes a few seconds, because the bank has to approve it. That sounds like a new kind of problem, but we’ve already solved it once: the motor. The motor also takes time and reports back later as an event. A card works the same way.

Start with what stays the same. The coin path, the escrow and the change maker don’t change at all. A card purchase simply has no coins and no change.

Here is what’s new:

  1. A Payment interface. Coins are one kind of payment, and a card is another. The machine asks the payment two things: “can this pay for the item?” and “finish the payment” or “cancel it”.
  2. An Authorizing state. When the customer taps a card and picks an item, the machine asks the bank to approve the amount and moves to Authorizing. The bank’s answer comes back later, as an event on the same queue as everything else.
  3. Two new events. approved moves the machine to Dispensing, exactly like a coin sale. declined shows “Card declined” and goes back to Idle.

What about cancel? While the machine is in Authorizing, cancel tells the bank to drop the request (this is called voiding it) and goes back to Idle. If the bank’s approval arrives after that, it lands in Idle, which ignores it, and the void makes sure the customer isn’t charged.

And if the item doesn’t drop? With coins, we returned the escrow. With a card, we never captured the money. Banks split a card payment into two steps: first “hold this amount” (authorize), then “take it” (capture). We authorize before the motor runs and capture only after the drop sensor says yes. If the item sticks, we release the hold instead. It’s the same “reserve, act, decide” idea from stage 4.

Notice that the event queue did the hard work again. The bank’s reply, a cancel, and a coin can all arrive at once, but they’re handled one at a time, in order. So Authorizing needs no locks.

Go deeperAdding operator alerts, step by step2 min

The operator wants to know when a slot jams, when an item sells out, and when the machine is low on a kind of coin. The tempting fix is to add a line like phone.sendAlert(...) inside Dispensing. That works once. But then telemetry wants the same events, and a screen in the back office wants them too, and each one adds another line inside the states.

Instead, the machine keeps a list of listeners. A listener is any object with one method, onEvent(event). The states don’t know who is listening. They just tell the machine “this happened”, and the machine passes it to everyone on the list. This is the Observer pattern.

Here is how it fits, in three steps:

  1. Name the events. SlotJammed(slot), SoldOut(slot), LowChange(coin, count). Each is a small record with the facts, and nothing else.
  2. Send them from one place each. Dispensing.dispensed sends SlotJammed when the drop fails. Finishing a sale sends SoldOut when a slot reaches zero, and LowChange when a coin count drops below the operator’s limit.
  3. Add listeners when the machine is built. An OperatorAlerts listener sends a text message. A Telemetry listener collects events and sends them to the backend every few minutes.

There is one trap. A listener runs on the event queue’s thread. If the text message takes three seconds to send, every coin and button press waits three seconds behind it. So a listener must hand slow work to another thread (or its own queue) and return at once. The same rule we gave the motor applies here: nothing on the queue’s thread is allowed to wait.

And if a listener throws an error? The machine catches it, logs it, and moves on. A broken alert should never stop someone from buying a drink.

Go deeperThree more follow-up questions2 min
Interviewer asksHow do you stop the machine running out of change?

Track the cash box, and warn the operator when any kind of coin drops below a set level (a listener). Some machines also prefer giving out the coins they have most of. A different ChangeMaker can do that: among the valid answers, pick the one that leaves the most even supply.

Interviewer asksA customer says they paid and got nothing. How would you check?

Log every event, and every finished or rolled-back sale, with a time. The log shows whether the drop sensor reported success. The slot count and cash box can then be checked against it at the next restock. The event queue makes this easy: the queue order is the log order.

Interviewer asksWhy is Hardware one interface rather than Motor, CoinReturn and Display?

At this size, one interface keeps the constructor and the fakes small. If a machine model had no display, or two coin returns, I’d split it, because then the parts change separately. That’s the interface segregation principle, applied when it pays off, not up front.