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.
- Read first
- Object modelingStateStrategy
- Patterns
- StateStrategyAdapter and facade
- Related
- ATMParking lot
“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?
The answer in one minute
Section titled “The answer in one minute”If you only have a minute, this is the design. The rest of the page explains how we get here.
- 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.
- 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.
- 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.
- 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.
- 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.
Scope it
Section titled “Scope it”Before drawing anything, we ask a few questions. Each answer changes a class. Anything that doesn’t, we assume and move on.
Design a vending machine.
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 |
Build it
Section titled “Build it”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).
Stage 1: Sell one thing
Section titled “Stage 1: Sell one thing”Start simple. A customer puts in a dollar and presses A1. What happens?
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.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// 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); }}from dataclasses import dataclassfrom enum import Enum
# The coins the machine accepts, each with its value. Money is integer cents, never a float.class Coin(Enum): NICKEL = 5 DIME = 10 QUARTER = 25 DOLLAR = 100
@property def cents(self) -> int: return self.value
# What is for sale. A value: two equal products are interchangeable.@dataclass(frozen=True)class Product: name: str price_cents: int
# A slot is one coil in the machine: it has a keypad code, holds one product, and runs out.class Slot: def __init__(self, code: str, product: Product, count: int): self.code = code self.product = product self._count = count
def available(self) -> bool: return self._count > 0
def take(self) -> None: self._count -= 1
# What a sale hands back: the product, and how much change is owed.@dataclass(frozen=True)class Sale: product: Product change_cents: int
# The one class the keypad and coin slot talk to.class VendingMachine: def __init__(self, slots: list[Slot]): self._slots = {s.code: s for s in slots} self._balance = 0 # cents inserted so far
def insert(self, c: Coin) -> None: self._balance += c.cents
# The sale, or None if it can't happen right now. def select(self, code: str) -> Sale | None: s = self._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 is None or not s.available() or self._balance < s.product.price_cents: return None 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(s.product, self._balance - s.product.price_cents) self._balance = 0 return sale// The coins the machine accepts, each valued in cents. Money is integer cents, never a double.public enum Coin { Nickel = 5, Dime = 10, Quarter = 25, Dollar = 100 }
// What is for sale. A value: two equal products are interchangeable.public sealed 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.public sealed class Slot(string code, Product product, int count){ public string Code { get; } = code; public Product Product { get; } = product; private int _count = count;
public bool Available => _count > 0;
internal void Take() => _count--;}
// What a sale hands back: the product, and how much change is owed.public sealed record Sale(Product Product, int ChangeCents);
// The one class the keypad and coin slot talk to.public sealed class VendingMachine(IEnumerable<Slot> slots){ private readonly Dictionary<string, Slot> _slots = slots.ToDictionary(s => s.Code); private int _balance; // cents inserted so far
public void Insert(Coin c) => _balance += (int)c;
// The sale, or null if it can't happen right now. public Sale? Select(string 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 (!_slots.TryGetValue(code, out var s) || !s.Available || _balance < s.Product.PriceCents) return null; 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. var sale = new Sale(s.Product, _balance - s.Product.PriceCents); _balance = 0; return sale; }}// Coin is a coin the machine accepts; its value is its worth in cents.// Money is integer cents, never a float.type Coin int
const ( Nickel Coin = 5 Dime Coin = 10 Quarter Coin = 25 Dollar Coin = 100)
// Product is what is for sale. A value: two equal products are interchangeable.type Product struct { Name string PriceCents int}
// Slot is one coil in the machine: it has a keypad code, holds one product, and runs out.type Slot struct { Code string Product Product count int}
func (s *Slot) Available() bool { return s.count > 0 }
func (s *Slot) take() { s.count-- }
// Sale is what a purchase hands back: the product, and how much change is owed.type Sale struct { Product Product ChangeCents int}
// VendingMachine is the one type the keypad and coin slot talk to.type VendingMachine struct { slots map[string]*Slot balance int // cents inserted so far}
func (m *VendingMachine) Insert(c Coin) { m.balance += int(c) }
// Select returns the sale, or ok=false if it can't happen right now.func (m *VendingMachine) Select(code string) (sale Sale, ok bool) { s := m.slots[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 == nil || !s.Available() || m.balance < s.Product.PriceCents { return Sale{}, false } 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{s.Product, m.balance - s.Product.PriceCents} m.balance = 0 return sale, true}// The coins the machine accepts, each with its value. Money is integer cents, never a float.const Coin = { NICKEL: 5, DIME: 10, QUARTER: 25, DOLLAR: 100 } as const;type Coin = (typeof Coin)[keyof typeof Coin];
// What is for sale. A value: two equal products are interchangeable.interface Product { readonly name: string; readonly priceCents: number;}
// A slot is one coil in the machine: it has a keypad code, holds one product, and runs out.class Slot { constructor( readonly code: string, readonly product: Product, private count: number, ) {}
available(): boolean { return this.count > 0; }
take(): void { this.count--; }}
// What a sale hands back: the product, and how much change is owed.interface Sale { readonly product: Product; readonly changeCents: number;}
// The one class the keypad and coin slot talk to.class VendingMachine { private readonly slots: Map<string, Slot>; private balance = 0; // cents inserted so far
constructor(slots: Slot[]) { this.slots = new Map(slots.map((s) => [s.code, s])); }
insert(c: Coin): void { this.balance += c; }
/** The sale, or null if it can't happen right now. */ select(code: string): Sale | null { const s = this.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 || !s.available() || this.balance < s.product.priceCents) return null; 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. const sale = { product: s.product, changeCents: this.balance - s.product.priceCents }; this.balance = 0; return 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”The motor takes two seconds. What if someone presses cancel, or puts in a coin, while it’s running?
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:
- 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.
- 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.
// 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"); }}from typing import Protocol
# The machine's outputs: a motor, a coin return and a display. A protocol (a port), so the# real hardware and a test's fakes plug in the same way.class Hardware(Protocol): def start_dispense(self, slot_code: str) -> None: ... # returns at once; the motor reports back via dispensed(ok)
def return_coins(self, cents: int) -> None: ... # stage 3 turns this into real coins
def show(self, message: str) -> None: ...
# 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.class MachineState: def insert(self, m: "VendingMachine", c: Coin) -> None: m.hardware.return_coins(c.cents) # drop it back out
def select(self, m: "VendingMachine", code: str) -> None: m.hardware.show("Please wait")
def cancel(self, m: "VendingMachine") -> None: pass
def dispensed(self, m: "VendingMachine", ok: bool) -> None: pass
# Waiting for a customer. A coin starts a sale; a button just shows the price.class Idle(MachineState): def insert(self, m, c): m.balance += c.cents m.state = HasMoney() m.hardware.show(f"Credit {m.balance}")
def select(self, m, code): s = m.slots.get(code) m.hardware.show("No such item" if s is None else f"Price {s.product.price_cents}")
# Money is in. This is the only state where a sale can start, so every sale rule lives here.class HasMoney(MachineState): def insert(self, m, c): m.balance += c.cents m.hardware.show(f"Credit {m.balance}")
def select(self, m, code): s = m.slots.get(code) if s is None: return m.hardware.show("No such item") if not s.available(): return m.hardware.show("Sold out") price = s.product.price_cents if m.balance < price: return m.hardware.show(f"Insert {price - m.balance} more") m.pending = s # State first, then the motor: a fast motor may call dispensed() before # start_dispense even returns, and it must find the machine already DISPENSING. m.state = Dispensing() m.hardware.start_dispense(code)
def cancel(self, m): m.hardware.return_coins(m.balance) m.balance = 0 m.state = 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(MachineState): def dispensed(self, m, ok): # flaw: assumes the motor worked. Stage 4 handles a jam. m.pending.take() m.hardware.return_coins(m.balance - m.pending.product.price_cents) m.balance = 0 m.pending = None m.state = Idle()
# The operator has the door open. Nothing sells; coins drop back out (the default).class OutOfService(MachineState): def select(self, m, code): m.hardware.show("Out of service")// 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.public interface IHardware{ void StartDispense(string slotCode); // returns at once; the motor reports back via Dispensed(ok) void ReturnCents(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.public interface IMachineState{ void Insert(VendingMachine m, Coin c) => m.Hardware.ReturnCents((int)c); // drop it back out void Select(VendingMachine m, string code) => m.Hardware.Show("Please wait"); void Cancel(VendingMachine m) { } void Dispensed(VendingMachine m, bool ok) { }}
// Waiting for a customer. A coin starts a sale; a button just shows the price.public sealed class Idle : IMachineState{ public void Insert(VendingMachine m, Coin c) { m.Balance += (int)c; m.State = VendingMachine.HasMoneyState; m.Hardware.Show($"Credit {m.Balance}"); }
public void Select(VendingMachine m, string code) => m.Hardware.Show(m.Slots.TryGetValue(code, out var s) ? $"Price {s.Product.PriceCents}" : "No such item");}
// Money is in. This is the only state where a sale can start, so every sale rule lives here.public sealed class HasMoney : IMachineState{ public void Insert(VendingMachine m, Coin c) { m.Balance += (int)c; m.Hardware.Show($"Credit {m.Balance}"); }
public void Select(VendingMachine m, string code) { if (!m.Slots.TryGetValue(code, out var s)) { 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.State = VendingMachine.DispensingState; m.Hardware.StartDispense(code); }
public void Cancel(VendingMachine m) { m.Hardware.ReturnCents(m.Balance); m.Balance = 0; m.State = VendingMachine.IdleState; }}
// The motor is running. Too late to cancel; coins drop straight back out.// Only the motor's report moves the machine on.public sealed class Dispensing : IMachineState{ public void Dispensed(VendingMachine m, bool ok) { // flaw: assumes the motor worked. Stage 4 handles a jam. var s = m.Pending!; s.Take(); m.Hardware.ReturnCents(m.Balance - s.Product.PriceCents); m.Balance = 0; m.Pending = null; m.State = VendingMachine.IdleState; }}
// The operator has the door open. Nothing sells; coins drop back out (the default).public sealed class OutOfService : IMachineState{ public void Select(VendingMachine m, string code) => m.Hardware.Show("Out of service");}// Hardware is 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.type Hardware interface { StartDispense(slotCode string) // returns at once; the motor reports back via Dispensed(ok) ReturnCents(cents int) // stage 3 turns this into real coins Show(message string)}
// State has one method per thing that can happen to the machine. Each state decides what// it means right now.type State interface { Insert(m *VendingMachine, c Coin) Select(m *VendingMachine, code string) Cancel(m *VendingMachine) Dispensed(m *VendingMachine, ok bool)}
// notNow holds the defaults: drop the coin back out, ask the customer to wait, ignore the// rest. States embed it and write only the events they handle.type notNow struct{}
func (notNow) Insert(m *VendingMachine, c Coin) { m.hw.ReturnCents(int(c)) }func (notNow) Select(m *VendingMachine, code string) { m.hw.Show("Please wait") }func (notNow) Cancel(m *VendingMachine) {}func (notNow) Dispensed(m *VendingMachine, ok bool) {}
// idle is waiting for a customer. A coin starts a sale; a button just shows the price.type idle struct{ notNow }
func (idle) Insert(m *VendingMachine, c Coin) { m.balance += int(c) m.state = hasMoney{} m.hw.Show(fmt.Sprintf("Credit %d", m.balance))}
func (idle) Select(m *VendingMachine, code string) { if s := m.slots[code]; s != nil { m.hw.Show(fmt.Sprintf("Price %d", s.Product.PriceCents)) } else { m.hw.Show("No such item") }}
// hasMoney is the only state where a sale can start, so every sale rule lives here.type hasMoney struct{ notNow }
func (hasMoney) Insert(m *VendingMachine, c Coin) { m.balance += int(c) m.hw.Show(fmt.Sprintf("Credit %d", m.balance))}
func (hasMoney) Select(m *VendingMachine, code string) { s := m.slots[code] switch { case s == nil: m.hw.Show("No such item") case !s.Available(): m.hw.Show("Sold out") case m.balance < s.Product.PriceCents: m.hw.Show(fmt.Sprintf("Insert %d more", s.Product.PriceCents-m.balance)) default: 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.state = dispensing{} m.hw.StartDispense(code) }}
func (hasMoney) Cancel(m *VendingMachine) { m.hw.ReturnCents(m.balance) m.balance = 0 m.state = idle{}}
// dispensing: the motor is running. Too late to cancel; coins drop straight back out.// Only the motor's report moves the machine on.type dispensing struct{ notNow }
func (dispensing) Dispensed(m *VendingMachine, ok bool) { // flaw: assumes the motor worked. Stage 4 handles a jam. m.pending.take() m.hw.ReturnCents(m.balance - m.pending.Product.PriceCents) m.balance = 0 m.pending = nil m.state = idle{}}
// outOfService: the operator has the door open. Nothing sells; coins drop back out.type outOfService struct{ notNow }
func (outOfService) Select(m *VendingMachine, code string) { m.hw.Show("Out of service") }// 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 { startDispense(slotCode: string): void; // returns at once; the motor reports back via dispensed(ok) returnCents(cents: number): void; // stage 3 turns this into real coins show(message: string): void;}
// One method per thing that can happen to the machine. Each state decides what it means// right now.interface MachineState { insert(m: VendingMachine, c: Coin): void; select(m: VendingMachine, code: string): void; cancel(m: VendingMachine): void; dispensed(m: VendingMachine, ok: boolean): void;}
// The defaults are "not now", so each state only writes the events it handles.abstract class NotNow implements MachineState { insert(m: VendingMachine, c: Coin): void { m.hardware.returnCents(c); // drop it back out } select(m: VendingMachine, code: string): void { m.hardware.show('Please wait'); } cancel(m: VendingMachine): void {} dispensed(m: VendingMachine, ok: boolean): void {}}
// Waiting for a customer. A coin starts a sale; a button just shows the price.class Idle extends NotNow { insert(m: VendingMachine, c: Coin): void { m.balance += c; m.state = HAS_MONEY; m.hardware.show(`Credit ${m.balance}`); } select(m: VendingMachine, code: string): void { const s = m.slots.get(code); m.hardware.show(s ? `Price ${s.product.priceCents}` : 'No such item'); }}
// Money is in. This is the only state where a sale can start, so every sale rule lives here.class HasMoney extends NotNow { insert(m: VendingMachine, c: Coin): void { m.balance += c; m.hardware.show(`Credit ${m.balance}`); } select(m: VendingMachine, code: string): void { const s = m.slots.get(code); if (!s) return m.hardware.show('No such item'); if (!s.available()) return m.hardware.show('Sold out'); const price = s.product.priceCents; if (m.balance < price) return m.hardware.show(`Insert ${price - m.balance} more`); 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.state = DISPENSING; m.hardware.startDispense(code); } cancel(m: VendingMachine): void { m.hardware.returnCents(m.balance); m.balance = 0; m.state = 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 extends NotNow { dispensed(m: VendingMachine, ok: boolean): void { // flaw: assumes the motor worked. Stage 4 handles a jam. const s = m.pending!; s.take(); m.hardware.returnCents(m.balance - s.product.priceCents); m.balance = 0; m.pending = null; m.state = IDLE; }}
// The operator has the door open. Nothing sells; coins drop back out (the default).class OutOfService extends NotNow { select(m: VendingMachine, code: string): void { m.hardware.show('Out of service'); }}
// States hold no data, so one instance of each is shared. The data lives in the machine.const IDLE = new Idle();const HAS_MONEY = new HasMoney();const DISPENSING = new Dispensing();const OUT_OF_SERVICE = new OutOfService();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; }}class VendingMachine: def __init__(self, slots: list[Slot], hardware: Hardware): # Public by convention only for the states: they read and change these. self.hardware = hardware self.slots = {s.code: s for s in slots} self.balance = 0 # cents inserted for the current sale self.pending: Slot | None = None # the slot being dispensed; set only while DISPENSING self.state: MachineState = Idle() # states hold no data, so a new one per move is cheap
# Every event goes to the current state. There is no "if state ==" anywhere in this class. def insert(self, c: Coin) -> None: self.state.insert(self, c)
def select(self, code: str) -> None: self.state.select(self, code)
def cancel(self) -> None: self.state.cancel(self)
def dispensed(self, ok: bool) -> None: self.state.dispensed(self, ok)
# The operator can only take the machine out of service between customers. def service_mode(self, on: bool) -> bool: if on and isinstance(self.state, Idle): self.state = OutOfService() return True if not on and isinstance(self.state, OutOfService): self.state = Idle() return True return Falsepublic sealed class VendingMachine{ // States hold no data, so one instance of each is shared. The data lives here. internal static readonly IMachineState IdleState = new Idle(), HasMoneyState = new HasMoney(), DispensingState = new Dispensing(), OutOfServiceState = new OutOfService();
// Internal: only the states read and change these. internal IHardware Hardware { get; } internal Dictionary<string, Slot> Slots { get; } internal int Balance { get; set; } // cents inserted for the current sale internal Slot? Pending { get; set; } // the slot being dispensed; set only while dispensing internal IMachineState State { get; set; } = IdleState;
public VendingMachine(IEnumerable<Slot> slots, IHardware hardware) { Slots = slots.ToDictionary(s => s.Code); Hardware = hardware; }
// Every event goes to the current state. There is no "if state ==" anywhere in this class. public void Insert(Coin c) => State.Insert(this, c); public void Select(string code) => State.Select(this, code); public void Cancel() => State.Cancel(this); public void Dispensed(bool ok) => State.Dispensed(this, ok);
// The operator can only take the machine out of service between customers. public bool ServiceMode(bool on) { if (on && State == IdleState) { State = OutOfServiceState; return true; } if (!on && State == OutOfServiceState) { State = IdleState; return true; } return false; }}// VendingMachine holds the data; the states hold the behaviour. States are empty structs,// so switching state allocates nothing.type VendingMachine struct { hw Hardware slots map[string]*Slot balance int // cents inserted for the current sale pending *Slot // the slot being dispensed; set only while dispensing state State}
func NewVendingMachine(slots []*Slot, hw Hardware) *VendingMachine { m := &VendingMachine{hw: hw, slots: map[string]*Slot{}, state: idle{}} for _, s := range slots { m.slots[s.Code] = s } return m}
// Every event goes to the current state. There is no "if state ==" anywhere in this type.func (m *VendingMachine) Insert(c Coin) { m.state.Insert(m, c) }func (m *VendingMachine) Select(code string) { m.state.Select(m, code) }func (m *VendingMachine) Cancel() { m.state.Cancel(m) }func (m *VendingMachine) Dispensed(ok bool) { m.state.Dispensed(m, ok) }
// ServiceMode can only take the machine out of service between customers.func (m *VendingMachine) ServiceMode(on bool) bool { switch m.state.(type) { case idle: if on { m.state = outOfService{} return true } case outOfService: if !on { m.state = idle{} return true } } return false}class VendingMachine { // Public only so the states can reach them; nothing else should. readonly slots: Map<string, Slot>; balance = 0; // cents inserted for the current sale pending: Slot | null = null; // the slot being dispensed; set only while DISPENSING state: MachineState = IDLE;
constructor( slots: Slot[], readonly hardware: Hardware, ) { this.slots = new Map(slots.map((s) => [s.code, s])); }
// Every event goes to the current state. There is no "if state ===" anywhere in this class. insert(c: Coin): void { this.state.insert(this, c); } select(code: string): void { this.state.select(this, code); } cancel(): void { this.state.cancel(this); } dispensed(ok: boolean): void { this.state.dispensed(this, ok); }
// The operator can only take the machine out of service between customers. serviceMode(on: boolean): boolean { if (on && this.state === IDLE) this.state = OUT_OF_SERVICE; else if (!on && this.state === OUT_OF_SERVICE) this.state = IDLE; else return false; return true; }}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.
Stage 3: Change from real coins
Section titled “Stage 3: Change from real coins”The machine owes 30 cents and has one quarter and three dimes left. What does it give back?
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.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// 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; }}from collections import Counter
# A bag of coins counted by kind. The escrow, the cash box and a handful of change are all one.# Counter already does the counting; total() and as_list() are the two things we add.class CoinBox(Counter): def total_cents(self) -> int: return sum(c.cents * n for c, n in self.items())
def as_list(self) -> list[Coin]: return sorted(self.elements(), key=lambda c: c.cents)
# How to pay out an amount from the coins on hand. None means it can't be done exactly.class ChangeMaker(Protocol): def make(self, cents: int, available: CoinBox) -> CoinBox | None: ...
# 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: def make(self, cents: int, available: CoinBox) -> CoinBox | None: out = CoinBox() for c in sorted(Coin, key=lambda c: -c.cents): n = min(cents // c.cents, available[c]) out[c] += n cents -= n * c.cents return CoinBox(+out) if cents == 0 else None # unary + drops zero counts
# 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: def make(self, cents: int, available: CoinBox) -> CoinBox | None: kinds = sorted(Coin, key=lambda c: -c.cents)
def fill(i: int, cents: int) -> CoinBox | None: if cents == 0: return CoinBox() if i == len(kinds): return None # ran out of coin kinds with money still owed c = kinds[i] for n in range(min(cents // c.cents, available[c]), -1, -1): rest = fill(i + 1, cents - n * c.cents) if rest is not None: rest[c] += n return CoinBox(+rest) return None
return fill(0, cents)// A bag of coins counted by kind. The escrow, the cash box and a handful of change are all one.public sealed class CoinBox{ private readonly Dictionary<Coin, int> _counts = new();
public void Add(Coin c, int n = 1) => _counts[c] = Count(c) + n; public void AddAll(CoinBox other) { foreach (var (c, n) in other._counts) Add(c, n); } public void RemoveAll(CoinBox other) { foreach (var (c, n) in other._counts) Add(c, -n); } public int Count(Coin c) => _counts.GetValueOrDefault(c); public int Total => _counts.Sum(e => (int)e.Key * e.Value); public void Clear() => _counts.Clear(); public List<Coin> ToList() => _counts.SelectMany(e => Enumerable.Repeat(e.Key, e.Value)).ToList();}
// How to pay out an amount from the coins on hand. Null means it can't be done exactly.public interface IChangeMaker{ 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.public sealed class GreedyChange : IChangeMaker{ public CoinBox? Make(int cents, CoinBox available) { var output = new CoinBox(); foreach (var c in Enum.GetValues<Coin>().OrderByDescending(c => (int)c)) { int n = Math.Min(cents / (int)c, available.Count(c)); if (n > 0) output.Add(c, n); cents -= n * (int)c; } return cents == 0 ? output : null; }}
// 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.public sealed class BacktrackingChange : IChangeMaker{ private static readonly Coin[] BiggestFirst = Enum.GetValues<Coin>().OrderByDescending(c => (int)c).ToArray();
public CoinBox? Make(int cents, CoinBox available) { var output = new CoinBox(); return Fill(0, cents) ? output : null;
bool Fill(int i, int owed) { if (owed == 0) return true; if (i == BiggestFirst.Length) return false; // ran out of coin kinds with money owed var c = BiggestFirst[i]; for (int n = Math.Min(owed / (int)c, available.Count(c)); n >= 0; n--) { if (Fill(i + 1, owed - n * (int)c)) { if (n > 0) output.Add(c, n); return true; } } return false; } }}// CoinBox is a bag of coins counted by kind. The escrow, the cash box and a handful of// change are all one.type CoinBox map[Coin]int
func (b CoinBox) AddAll(o CoinBox) { for c, n := range o { b[c] += n }}
func (b CoinBox) RemoveAll(o CoinBox) { for c, n := range o { b[c] -= n }}
func (b CoinBox) Total() int { t := 0 for c, n := range b { t += int(c) * n } return t}
// List returns the coins smallest first, so output is the same every run.func (b CoinBox) List() []Coin { var out []Coin for _, c := range allCoins { for range b[c] { out = append(out, c) } } return out}
var allCoins = []Coin{Nickel, Dime, Quarter, Dollar} // smallest first
// ChangeMaker pays out an amount from the coins on hand; ok=false means it can't be done exactly.type ChangeMaker interface { Make(cents int, available CoinBox) (change CoinBox, ok bool)}
// GreedyChange takes the 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.type GreedyChange struct{}
func (GreedyChange) Make(cents int, available CoinBox) (CoinBox, bool) { out := CoinBox{} for i := len(allCoins) - 1; i >= 0; i-- { c := allCoins[i] n := min(cents/int(c), available[c]) if n > 0 { out[c] = n } cents -= n * int(c) } return out, cents == 0}
// BacktrackingChange is greedy first, but when the rest can't be made it backs off one big// coin and tries again. It 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.type BacktrackingChange struct{}
func (BacktrackingChange) Make(cents int, available CoinBox) (CoinBox, bool) { out := CoinBox{} var fill func(i, cents int) bool fill = func(i, cents int) bool { if cents == 0 { return true } if i < 0 { return false // ran out of coin kinds with money still owed } c := allCoins[i] for n := min(cents/int(c), available[c]); n >= 0; n-- { if fill(i-1, cents-n*int(c)) { if n > 0 { out[c] = n } return true } } return false } return out, fill(len(allCoins)-1, cents)}// A bag of coins counted by kind. The escrow, the cash box and a handful of change are all one.class CoinBox { private readonly counts = new Map<Coin, number>();
static of(entries: [Coin, number][]): CoinBox { const b = new CoinBox(); for (const [c, n] of entries) b.add(c, n); return b; }
add(c: Coin, n = 1): void { this.counts.set(c, this.count(c) + n); } addAll(o: CoinBox): void { for (const [c, n] of o.counts) this.add(c, n); } removeAll(o: CoinBox): void { for (const [c, n] of o.counts) this.add(c, -n); } count(c: Coin): number { return this.counts.get(c) ?? 0; } total(): number { let t = 0; for (const [c, n] of this.counts) t += c * n; return t; } clear(): void { this.counts.clear(); } toList(): Coin[] { return [...this.counts].flatMap(([c, n]) => Array<Coin>(n).fill(c)); }}
const BIGGEST_FIRST: Coin[] = [Coin.DOLLAR, Coin.QUARTER, Coin.DIME, Coin.NICKEL];
// How to pay out an amount from the coins on hand. null means it can't be done exactly.interface ChangeMaker { make(cents: number, available: CoinBox): CoinBox | null;}
// 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 { make(cents: number, available: CoinBox): CoinBox | null { const out = new CoinBox(); for (const c of BIGGEST_FIRST) { const n = Math.min(Math.floor(cents / c), available.count(c)); if (n > 0) out.add(c, n); cents -= n * c; } return cents === 0 ? out : null; }}
// 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 { make(cents: number, available: CoinBox): CoinBox | null { const out = new CoinBox(); const fill = (i: number, cents: number): boolean => { if (cents === 0) return true; if (i === BIGGEST_FIRST.length) return false; // ran out of coin kinds with money owed const c = BIGGEST_FIRST[i]; for (let n = Math.min(Math.floor(cents / c), available.count(c)); n >= 0; n--) { if (fill(i + 1, cents - n * c)) { if (n > 0) out.add(c, n); return true; } } return false; }; return fill(0, cents) ? out : null; }}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.
// 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 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.class Hardware(Protocol): def start_dispense(self, slot_code: str) -> None: ...
def return_coins(self, coins: list[Coin]) -> None: ... # the very coins, not just an amount
def show(self, message: str) -> None: ...
# 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.class MachineState: def insert(self, m: "VendingMachine", c: Coin) -> None: m.hardware.return_coins([c]) # a coin the machine can't take drops back out as itself
def select(self, m: "VendingMachine", code: str) -> None: m.hardware.show("Please wait")
def cancel(self, m: "VendingMachine") -> None: pass
def dispensed(self, m: "VendingMachine", ok: bool) -> None: pass
# Waiting for a customer. A button press just shows the price.class Idle(MachineState): def insert(self, m, c): m.escrow[c] += 1 # held apart from the cash box until the sale completes m.state = HasMoney() m.hardware.show(f"Credit {m.escrow.total_cents()}")
def select(self, m, code): s = m.slots.get(code) m.hardware.show("No such item" if s is None else f"Price {s.product.price_cents}")
# Holding the customer's coins. A selection checks stock, credit and change before anything# moves.class HasMoney(MachineState): def insert(self, m, c): m.escrow[c] += 1 m.hardware.show(f"Credit {m.escrow.total_cents()}")
def select(self, m, code): s = m.slots.get(code) if s is None: return m.hardware.show("No such item") if not s.available(): return m.hardware.show("Sold out") credit, price = m.escrow.total_cents(), s.product.price_cents if credit < price: return m.hardware.show(f"Insert {price - credit} more") # 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. change = m.change_maker.make(credit - price, m.cash + m.escrow) if change is None: return m.hardware.show("Exact change only") # credit kept; pick again or cancel m.pending, m.pending_change = s, change m.state = Dispensing() m.hardware.start_dispense(code)
# Cancel returns the very coins the customer put in, not "the same amount". def cancel(self, m): m.hardware.return_coins(m.escrow.as_list()) m.escrow.clear() m.state = Idle()
class Dispensing(MachineState): def dispensed(self, m, ok): # flaw: still assumes the motor worked. Stage 4 handles a jam. m.pending.take() m.cash.update(m.escrow) # the sale is done: escrow becomes the machine's money m.cash.subtract(m.pending_change) m.hardware.return_coins(m.pending_change.as_list()) m.escrow.clear() m.pending = m.pending_change = None m.state = Idle()
# The keypad and coin slot call this; it hands each event to the current state.class VendingMachine: def __init__(self, slots: list[Slot], float_: CoinBox, change_maker: ChangeMaker, hardware: Hardware): self.hardware = hardware self.change_maker = change_maker # injected: greedy, backtracking, fewest-coins self.slots = {s.code: s for s in slots} self.cash = CoinBox(float_) # the machine's money, used for change self.escrow = CoinBox() # this customer's coins, returned as-is on cancel self.pending: Slot | None = None self.pending_change: CoinBox | None = None # decided before the motor starts self.state: MachineState = Idle() # states hold no data, so a new one per move is cheap
def insert(self, c: Coin) -> None: self.state.insert(self, c)
def select(self, code: str) -> None: self.state.select(self, code)
def cancel(self) -> None: self.state.cancel(self)
def dispensed(self, ok: bool) -> None: self.state.dispensed(self, ok)// 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.public interface IHardware{ void StartDispense(string slotCode); // returns at once; the motor reports back via Dispensed(ok) void ReturnCoins(IReadOnlyList<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", so each state only writes the events it handles.public interface IMachineState{ // A coin the machine can't take now drops back out as that same coin. void Insert(VendingMachine m, Coin c) => m.Hardware.ReturnCoins([c]); void Select(VendingMachine m, string code) => m.Hardware.Show("Please wait"); void Cancel(VendingMachine m) { } void Dispensed(VendingMachine m, bool ok) { }}
// Waiting for a customer. A button press just shows the price.public sealed class Idle : IMachineState{ public void Insert(VendingMachine m, Coin c) { m.Escrow.Add(c); // held apart from the cash box until the sale completes m.State = VendingMachine.HasMoneyState; m.Hardware.Show($"Credit {m.Escrow.Total}"); }
public void Select(VendingMachine m, string code) => m.Hardware.Show(m.Slots.TryGetValue(code, out var s) ? $"Price {s.Product.PriceCents}" : "No such item");}
// Holding the customer's coins. A selection checks stock, credit and change before anything moves.public sealed class HasMoney : IMachineState{ 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) { if (!m.Slots.TryGetValue(code, out var s)) { 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. var available = new CoinBox(); available.AddAll(m.Cash); available.AddAll(m.Escrow); var change = m.ChangeMaker.Make(credit - price, available); if (change is null) { m.Hardware.Show("Exact change only"); return; } // credit kept; pick again or cancel m.Pending = s; m.PendingChange = change; m.State = VendingMachine.DispensingState; 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.State = VendingMachine.IdleState; }}
public sealed class Dispensing : IMachineState{ public void Dispensed(VendingMachine m, bool 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.State = VendingMachine.IdleState; }}
// 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.public sealed class VendingMachine{ internal static readonly IMachineState IdleState = new Idle(), HasMoneyState = new HasMoney(), DispensingState = new Dispensing(), OutOfServiceState = new OutOfService();
internal IHardware Hardware { get; } internal IChangeMaker ChangeMaker { get; } // injected: greedy, backtracking, fewest-coins internal Dictionary<string, Slot> Slots { get; } internal CoinBox Cash { get; } = new(); // the machine's money, used for change internal CoinBox Escrow { get; } = new(); // this customer's coins, returned as-is on cancel internal Slot? Pending { get; set; } internal CoinBox? PendingChange { get; set; } // decided before the motor starts internal IMachineState State { get; set; } = IdleState;
public VendingMachine(IEnumerable<Slot> slots, CoinBox @float, IChangeMaker changeMaker, IHardware hardware) { Slots = slots.ToDictionary(s => s.Code); Cash.AddAll(@float); // the coins the operator loads for change ChangeMaker = changeMaker; Hardware = hardware; }
public void Insert(Coin c) => State.Insert(this, c); public void Select(string code) => State.Select(this, code); public void Cancel() => State.Cancel(this); public void Dispensed(bool ok) => State.Dispensed(this, ok);}// Hardware is 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.type Hardware interface { StartDispense(slotCode string) // returns at once; the motor reports back via Dispensed(ok) ReturnCoins(coins []Coin) // the very coins, not just an amount Show(message string)}
// A coin the machine can't take now drops back out as that same coin.func (notNow) Insert(m *VendingMachine, c Coin) { m.hw.ReturnCoins([]Coin{c}) }
func (idle) Insert(m *VendingMachine, c Coin) { m.escrow[c]++ // held apart from the cash box until the sale completes m.state = hasMoney{} m.hw.Show(fmt.Sprintf("Credit %d", m.escrow.Total()))}
func (hasMoney) Insert(m *VendingMachine, c Coin) { m.escrow[c]++ m.hw.Show(fmt.Sprintf("Credit %d", m.escrow.Total()))}
// Select checks stock, credit and change before anything moves.func (hasMoney) Select(m *VendingMachine, code string) { s := m.slots[code] credit := m.escrow.Total() switch { case s == nil: m.hw.Show("No such item") return case !s.Available(): m.hw.Show("Sold out") return case credit < s.Product.PriceCents: m.hw.Show(fmt.Sprintf("Insert %d more", s.Product.PriceCents-credit)) 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. available := CoinBox{} available.AddAll(m.cash) available.AddAll(m.escrow) change, ok := m.changeMaker.Make(credit-s.Product.PriceCents, available) if !ok { m.hw.Show("Exact change only") // credit kept; pick again or cancel return } m.pending, m.pendingChange = s, change m.state = dispensing{} m.hw.StartDispense(code)}
// Cancel returns the very coins the customer put in, not "the same amount".func (hasMoney) Cancel(m *VendingMachine) { m.hw.ReturnCoins(m.escrow.List()) m.escrow = CoinBox{} m.state = idle{}}
func (dispensing) Dispensed(m *VendingMachine, ok bool) { // 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.hw.ReturnCoins(m.pendingChange.List()) m.escrow, m.pending, m.pendingChange = CoinBox{}, nil, nil m.state = idle{}}
// VendingMachine is what the keypad and coin slot call; it hands each event to the current// state. The states hold no data of their own, so they are empty structs.type VendingMachine struct { hw Hardware changeMaker ChangeMaker // injected: greedy, backtracking, fewest-coins slots map[string]*Slot cash CoinBox // the machine's money, used for change escrow CoinBox // this customer's coins, returned as-is on cancel pending *Slot pendingChange CoinBox // decided before the motor starts state State}
func NewVendingMachine(slots []*Slot, float CoinBox, cm ChangeMaker, hw Hardware) *VendingMachine { m := &VendingMachine{hw: hw, changeMaker: cm, slots: map[string]*Slot{}, cash: CoinBox{}, escrow: CoinBox{}, state: idle{}} m.cash.AddAll(float) // the coins the operator loads for change for _, s := range slots { m.slots[s.Code] = s } return m}// 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 { startDispense(slotCode: string): void; // returns at once; the motor reports back via dispensed(ok) returnCoins(coins: Coin[]): void; // the very coins, not just an amount show(message: string): void;}
// The "not now" answer to every event. Each state extends it and writes only the events it// handles.abstract class NotNow implements MachineState { insert(m: VendingMachine, c: Coin): void { m.hardware.returnCoins([c]); // a coin the machine can't take drops back out as itself } select(m: VendingMachine, code: string): void { m.hardware.show('Please wait'); } cancel(m: VendingMachine): void {} dispensed(m: VendingMachine, ok: boolean): void {}}
// Waiting for a customer. A button press just shows the price.class Idle extends NotNow { insert(m: VendingMachine, c: Coin): void { m.escrow.add(c); // held apart from the cash box until the sale completes m.state = HAS_MONEY; m.hardware.show(`Credit ${m.escrow.total()}`); } select(m: VendingMachine, code: string): void { const s = m.slots.get(code); m.hardware.show(s ? `Price ${s.product.priceCents}` : 'No such item'); }}
// Holding the customer's coins. A selection checks stock, credit and change before anything moves.class HasMoney extends NotNow { insert(m: VendingMachine, c: Coin): void { m.escrow.add(c); m.hardware.show(`Credit ${m.escrow.total()}`); } select(m: VendingMachine, code: string): void { const s = m.slots.get(code); if (!s) return m.hardware.show('No such item'); if (!s.available()) return m.hardware.show('Sold out'); const credit = m.escrow.total(); const price = s.product.priceCents; if (credit < price) return m.hardware.show(`Insert ${price - credit} more`); // 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. const available = new CoinBox(); available.addAll(m.cash); available.addAll(m.escrow); const change = m.changeMaker.make(credit - price, available); if (!change) return m.hardware.show('Exact change only'); // credit kept; pick again or cancel m.pending = s; m.pendingChange = change; m.state = DISPENSING; m.hardware.startDispense(code); } // Cancel returns the very coins the customer put in, not "the same amount". cancel(m: VendingMachine): void { m.hardware.returnCoins(m.escrow.toList()); m.escrow.clear(); m.state = IDLE; }}
class Dispensing extends NotNow { dispensed(m: VendingMachine, ok: boolean): void { // 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 = m.pendingChange = null; m.state = IDLE; }}
// The keypad and coin slot call this; it hands each event to the current state.class VendingMachine { readonly slots: Map<string, Slot>; readonly cash = new CoinBox(); // the machine's money, used for change readonly escrow = new CoinBox(); // this customer's coins, returned as-is on cancel pending: Slot | null = null; pendingChange: CoinBox | null = null; // decided before the motor starts state: MachineState = IDLE;
constructor( slots: Slot[], float: CoinBox, // the coins the operator loads for change readonly changeMaker: ChangeMaker, // injected: greedy, backtracking, fewest-coins readonly hardware: Hardware, ) { this.slots = new Map(slots.map((s) => [s.code, s])); this.cash.addAll(float); }
insert(c: Coin): void { this.state.insert(this, c); } select(code: string): void { this.state.select(this, code); } cancel(): void { this.state.cancel(this); } dispensed(ok: boolean): void { this.state.dispensed(this, ok); }}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.
Stage 4: When the item doesn’t drop
Section titled “Stage 4: When the item doesn’t drop”The motor turns, but the bag catches on the coil. Who has the money now?
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:
- Reserve. In
select, check and set aside everything: the item is in stock, the credit is enough, and the change can be made. - Act. Run the motor.
- 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.
- Color key
- Interface
- Implementation
- Facade
// 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); }}# One coil of one product, known by its keypad code, like "B4".class Slot: def __init__(self, code: str, product: Product, count: int): self.code = code self.product = product self._count = count self._jammed = False
# A jammed coil is as good as empty until the operator clears it. def available(self) -> bool: return self._count > 0 and not self._jammed
def take(self) -> None: self._count -= 1
def jam(self) -> None: self._jammed = True
def clear_jam(self) -> None: self._jammed = False
# The motor is turning. Coins and buttons get the "not now" defaults until the drop sensor# reports back.class Dispensing(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. def dispensed(self, m, ok): if ok: # Commit: stock down, the customer's coins into the cash box, change out. m.pending.take() m.cash.update(m.escrow) m.cash.subtract(m.pending_change) m.hardware.return_coins(m.pending_change.as_list()) 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.return_coins(m.escrow.as_list()) m.hardware.show("Sorry, could not dispense. Coins returned.") m.escrow.clear() m.pending = m.pending_change = None m.state = Idle()// One coil of one product, known by its keypad code, like "B4".public sealed class Slot(string code, Product product, int count){ public string Code { get; } = code; public Product Product { get; } = product; private int _count = count; private bool _jammed;
// A jammed coil is as good as empty until the operator clears it. public bool Available => _count > 0 && !_jammed;
internal void Take() => _count--; internal void Jam() => _jammed = true; public void ClearJam() => _jammed = false;}
// The motor is turning. Coins and buttons get the "not now" defaults until the drop sensor// reports back.public sealed class Dispensing : IMachineState{ // 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, bool 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.State = VendingMachine.IdleState; }}// Slot is one coil of one product, known by its keypad code, like "B4".type Slot struct { Code string Product Product count int jammed bool}
// Available treats a jammed coil as empty until the operator clears it.func (s *Slot) Available() bool { return s.count > 0 && !s.jammed }
func (s *Slot) jam() { s.jammed = true }func (s *Slot) ClearJam() { s.jammed = false }
// Dispensed gets ok 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.func (dispensing) Dispensed(m *VendingMachine, ok bool) { 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.hw.ReturnCoins(m.pendingChange.List()) } 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.hw.ReturnCoins(m.escrow.List()) m.hw.Show("Sorry, could not dispense. Coins returned.") } m.escrow, m.pending, m.pendingChange = CoinBox{}, nil, nil m.state = idle{}}// One coil of one product, known by its keypad code, like "B4".class Slot { private jammed = false;
constructor( readonly code: string, readonly product: Product, private count: number, ) {}
// A jammed coil is as good as empty until the operator clears it. available(): boolean { return this.count > 0 && !this.jammed; } take(): void { this.count--; } jam(): void { this.jammed = true; } clearJam(): void { this.jammed = false; }}
// The motor is turning. Coins and buttons get the "not now" defaults until the drop sensor// reports back.class Dispensing extends NotNow { // 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. dispensed(m: VendingMachine, ok: boolean): void { 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 = m.pendingChange = null; m.state = 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.
The full design
Section titled “The full design”Here is everything together. Each class is here because a requirement needed it.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
// 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); } } }}# The complete design, built from every stage on this page.
from __future__ import annotations
import queuefrom collections import Counterfrom dataclasses import dataclassfrom enum import Enumfrom typing import Protocol
# The coins the machine accepts, each with its value. Money is integer cents, never a float.class Coin(Enum): NICKEL = 5 DIME = 10 QUARTER = 25 DOLLAR = 100
@property def cents(self) -> int: return self.value
# What is for sale. A value: two equal products are interchangeable.@dataclass(frozen=True)class Product: name: str price_cents: int
# One coil of one product, known by its keypad code, like "B4".class Slot: def __init__(self, code: str, product: Product, count: int): self.code = code self.product = product self._count = count self._jammed = False
# A jammed coil is as good as empty until the operator clears it. def available(self) -> bool: return self._count > 0 and not self._jammed
def take(self) -> None: self._count -= 1
def jam(self) -> None: self._jammed = True
def clear_jam(self) -> None: self._jammed = False
# The keypad and coin slot call this; it hands each event to the current state.class VendingMachine: def __init__(self, slots: list[Slot], float_: CoinBox, change_maker: ChangeMaker, hardware: Hardware): self.hardware = hardware self.change_maker = change_maker # injected: greedy, backtracking, fewest-coins self.slots = {s.code: s for s in slots} self.cash = CoinBox(float_) # the machine's money, used for change self.escrow = CoinBox() # this customer's coins, returned as-is on cancel self.pending: Slot | None = None self.pending_change: CoinBox | None = None # decided before the motor starts self.state: MachineState = Idle() # states hold no data, so a new one per move is cheap
def insert(self, c: Coin) -> None: self.state.insert(self, c)
def select(self, code: str) -> None: self.state.select(self, code)
def cancel(self) -> None: self.state.cancel(self)
def dispensed(self, ok: bool) -> None: self.state.dispensed(self, ok)
# 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.class Hardware(Protocol): def start_dispense(self, slot_code: str) -> None: ...
def return_coins(self, coins: list[Coin]) -> None: ... # the very coins, not just an amount
def show(self, message: str) -> None: ...
# 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.class MachineState: def insert(self, m: "VendingMachine", c: Coin) -> None: m.hardware.return_coins([c]) # a coin the machine can't take drops back out as itself
def select(self, m: "VendingMachine", code: str) -> None: m.hardware.show("Please wait")
def cancel(self, m: "VendingMachine") -> None: pass
def dispensed(self, m: "VendingMachine", ok: bool) -> None: pass
# Waiting for a customer. A button press just shows the price.class Idle(MachineState): def insert(self, m, c): m.escrow[c] += 1 # held apart from the cash box until the sale completes m.state = HasMoney() m.hardware.show(f"Credit {m.escrow.total_cents()}")
def select(self, m, code): s = m.slots.get(code) m.hardware.show("No such item" if s is None else f"Price {s.product.price_cents}")
# Holding the customer's coins. A selection checks stock, credit and change before anything# moves.class HasMoney(MachineState): def insert(self, m, c): m.escrow[c] += 1 m.hardware.show(f"Credit {m.escrow.total_cents()}")
def select(self, m, code): s = m.slots.get(code) if s is None: return m.hardware.show("No such item") if not s.available(): return m.hardware.show("Sold out") credit, price = m.escrow.total_cents(), s.product.price_cents if credit < price: return m.hardware.show(f"Insert {price - credit} more") # 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. change = m.change_maker.make(credit - price, m.cash + m.escrow) if change is None: return m.hardware.show("Exact change only") # credit kept; pick again or cancel m.pending, m.pending_change = s, change m.state = Dispensing() m.hardware.start_dispense(code)
# Cancel returns the very coins the customer put in, not "the same amount". def cancel(self, m): m.hardware.return_coins(m.escrow.as_list()) m.escrow.clear() m.state = Idle()
# The motor is turning. Coins and buttons get the "not now" defaults until the drop sensor# reports back.class Dispensing(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. def dispensed(self, m, ok): if ok: # Commit: stock down, the customer's coins into the cash box, change out. m.pending.take() m.cash.update(m.escrow) m.cash.subtract(m.pending_change) m.hardware.return_coins(m.pending_change.as_list()) 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.return_coins(m.escrow.as_list()) m.hardware.show("Sorry, could not dispense. Coins returned.") m.escrow.clear() m.pending = m.pending_change = None m.state = Idle()
# A bag of coins counted by kind. The escrow, the cash box and a handful of change are all one.# Counter already does the counting; total() and as_list() are the two things we add.class CoinBox(Counter): def total_cents(self) -> int: return sum(c.cents * n for c, n in self.items())
def as_list(self) -> list[Coin]: return sorted(self.elements(), key=lambda c: c.cents)
# How to pay out an amount from the coins on hand. None means it can't be done exactly.class ChangeMaker(Protocol): def make(self, cents: int, available: CoinBox) -> CoinBox | None: ...
# 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: def make(self, cents: int, available: CoinBox) -> CoinBox | None: out = CoinBox() for c in sorted(Coin, key=lambda c: -c.cents): n = min(cents // c.cents, available[c]) out[c] += n cents -= n * c.cents return CoinBox(+out) if cents == 0 else None # unary + drops zero counts
# 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: def make(self, cents: int, available: CoinBox) -> CoinBox | None: kinds = sorted(Coin, key=lambda c: -c.cents)
def fill(i: int, cents: int) -> CoinBox | None: if cents == 0: return CoinBox() if i == len(kinds): return None # ran out of coin kinds with money still owed c = kinds[i] for n in range(min(cents // c.cents, available[c]), -1, -1): rest = fill(i + 1, cents - n * c.cents) if rest is not None: rest[c] += n return CoinBox(+rest) return None
return fill(0, cents)
# Everything that can happen to the machine, as data. Hardware threads create these;# only the loop thread acts on them.@dataclass(frozen=True)class CoinInserted: coin: Coin
@dataclass(frozen=True)class Selected: code: str
@dataclass(frozen=True)class Cancelled: pass
@dataclass(frozen=True)class Dispensed: ok: bool
Event = CoinInserted | Selected | Cancelled | Dispensed
# 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: def __init__(self, machine: VendingMachine): self._machine = machine self._events: queue.Queue[Event | None] = queue.Queue()
# Safe from any thread, and never blocks the caller: the coin acceptor can't wait. def post(self, e: Event) -> None: self._events.put(e)
def stop(self) -> None: self._events.put(None) # handled after everything already queued
def run(self) -> None: while (e := self._events.get()) is not None: # strictly in arrival order try: match e: case CoinInserted(coin): self._machine.insert(coin) case Selected(code): self._machine.select(code) case Cancelled(): self._machine.cancel() case Dispensed(ok): self._machine.dispensed(ok) except Exception as bug: # One bad event must not stop the machine; log it and take the next. print(f"event {e} failed: {bug!r}")// The complete design, built from every stage on this page.
using System.Threading.Channels;
// The coins the machine accepts, each valued in cents. Money is integer cents, never a double.public enum Coin { Nickel = 5, Dime = 10, Quarter = 25, Dollar = 100 }
// What is for sale. A value: two equal products are interchangeable.public sealed record Product(string Name, int PriceCents);
// One coil of one product, known by its keypad code, like "B4".public sealed class Slot(string code, Product product, int count){ public string Code { get; } = code; public Product Product { get; } = product; private int _count = count; private bool _jammed;
// A jammed coil is as good as empty until the operator clears it. public bool Available => _count > 0 && !_jammed;
internal void Take() => _count--; internal void Jam() => _jammed = true; public 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.public sealed class VendingMachine{ internal static readonly IMachineState IdleState = new Idle(), HasMoneyState = new HasMoney(), DispensingState = new Dispensing(), OutOfServiceState = new OutOfService();
internal IHardware Hardware { get; } internal IChangeMaker ChangeMaker { get; } // injected: greedy, backtracking, fewest-coins internal Dictionary<string, Slot> Slots { get; } internal CoinBox Cash { get; } = new(); // the machine's money, used for change internal CoinBox Escrow { get; } = new(); // this customer's coins, returned as-is on cancel internal Slot? Pending { get; set; } internal CoinBox? PendingChange { get; set; } // decided before the motor starts internal IMachineState State { get; set; } = IdleState;
public VendingMachine(IEnumerable<Slot> slots, CoinBox @float, IChangeMaker changeMaker, IHardware hardware) { Slots = slots.ToDictionary(s => s.Code); Cash.AddAll(@float); // the coins the operator loads for change ChangeMaker = changeMaker; Hardware = hardware; }
public void Insert(Coin c) => State.Insert(this, c); public void Select(string code) => State.Select(this, code); public void Cancel() => State.Cancel(this); public void Dispensed(bool ok) => State.Dispensed(this, ok);}
// 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.public interface IHardware{ void StartDispense(string slotCode); // returns at once; the motor reports back via Dispensed(ok) void ReturnCoins(IReadOnlyList<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", so each state only writes the events it handles.public interface IMachineState{ // A coin the machine can't take now drops back out as that same coin. void Insert(VendingMachine m, Coin c) => m.Hardware.ReturnCoins([c]); void Select(VendingMachine m, string code) => m.Hardware.Show("Please wait"); void Cancel(VendingMachine m) { } void Dispensed(VendingMachine m, bool ok) { }}
// Waiting for a customer. A button press just shows the price.public sealed class Idle : IMachineState{ public void Insert(VendingMachine m, Coin c) { m.Escrow.Add(c); // held apart from the cash box until the sale completes m.State = VendingMachine.HasMoneyState; m.Hardware.Show($"Credit {m.Escrow.Total}"); }
public void Select(VendingMachine m, string code) => m.Hardware.Show(m.Slots.TryGetValue(code, out var s) ? $"Price {s.Product.PriceCents}" : "No such item");}
// Holding the customer's coins. A selection checks stock, credit and change before anything moves.public sealed class HasMoney : IMachineState{ 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) { if (!m.Slots.TryGetValue(code, out var s)) { 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. var available = new CoinBox(); available.AddAll(m.Cash); available.AddAll(m.Escrow); var change = m.ChangeMaker.Make(credit - price, available); if (change is null) { m.Hardware.Show("Exact change only"); return; } // credit kept; pick again or cancel m.Pending = s; m.PendingChange = change; m.State = VendingMachine.DispensingState; 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.State = VendingMachine.IdleState; }}
// The motor is turning. Coins and buttons get the "not now" defaults until the drop sensor// reports back.public sealed class Dispensing : IMachineState{ // 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, bool 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.State = VendingMachine.IdleState; }}
// The operator has the door open. Nothing sells; coins drop back out (the default).public sealed class OutOfService : IMachineState{ 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.public sealed class CoinBox{ private readonly Dictionary<Coin, int> _counts = new();
public void Add(Coin c, int n = 1) => _counts[c] = Count(c) + n; public void AddAll(CoinBox other) { foreach (var (c, n) in other._counts) Add(c, n); } public void RemoveAll(CoinBox other) { foreach (var (c, n) in other._counts) Add(c, -n); } public int Count(Coin c) => _counts.GetValueOrDefault(c); public int Total => _counts.Sum(e => (int)e.Key * e.Value); public void Clear() => _counts.Clear(); public List<Coin> ToList() => _counts.SelectMany(e => Enumerable.Repeat(e.Key, e.Value)).ToList();}
// How to pay out an amount from the coins on hand. Null means it can't be done exactly.public interface IChangeMaker{ 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.public sealed class GreedyChange : IChangeMaker{ public CoinBox? Make(int cents, CoinBox available) { var output = new CoinBox(); foreach (var c in Enum.GetValues<Coin>().OrderByDescending(c => (int)c)) { int n = Math.Min(cents / (int)c, available.Count(c)); if (n > 0) output.Add(c, n); cents -= n * (int)c; } return cents == 0 ? output : null; }}
// 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.public sealed class BacktrackingChange : IChangeMaker{ private static readonly Coin[] BiggestFirst = Enum.GetValues<Coin>().OrderByDescending(c => (int)c).ToArray();
public CoinBox? Make(int cents, CoinBox available) { var output = new CoinBox(); return Fill(0, cents) ? output : null;
bool Fill(int i, int owed) { if (owed == 0) return true; if (i == BiggestFirst.Length) return false; // ran out of coin kinds with money owed var c = BiggestFirst[i]; for (int n = Math.Min(owed / (int)c, available.Count(c)); n >= 0; n--) { if (Fill(i + 1, owed - n * (int)c)) { if (n > 0) output.Add(c, n); return true; } } return false; } }}
// Everything that can happen to the machine, as data. Hardware threads create these;// only the loop acts on them.public abstract record Event;
public sealed record CoinInserted(Coin Coin) : Event;
public sealed record Selected(string Code) : Event;
public sealed record Cancelled : Event;
public sealed record Dispensed(bool Ok) : Event;
// One loop 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.public sealed class MachineLoop(VendingMachine machine){ // Unbounded and single-reader: posting never blocks, and only RunAsync reads. private readonly Channel<Event> _events = Channel.CreateUnbounded<Event>(new UnboundedChannelOptions { SingleReader = true });
// Safe from any thread, and never blocks the caller: the coin acceptor can't wait. public void Post(Event e) => _events.Writer.TryWrite(e);
public void Stop() => _events.Writer.Complete(); // handled after everything already queued
public async Task RunAsync() { await foreach (var e in _events.Reader.ReadAllAsync()) // strictly in arrival order { try { switch (e) { case CoinInserted c: machine.Insert(c.Coin); break; case Selected s: machine.Select(s.Code); break; case Cancelled: machine.Cancel(); break; case Dispensed d: machine.Dispensed(d.Ok); break; } } catch (Exception bug) { // One bad event must not stop the machine; log it and take the next. Console.Error.WriteLine($"event {e} failed: {bug.Message}"); } } }}// The complete design, built from every stage on this page.
package vendingmachine
import ( "fmt" "log")
// Coin is a coin the machine accepts; its value is its worth in cents.// Money is integer cents, never a float.type Coin int
const ( Nickel Coin = 5 Dime Coin = 10 Quarter Coin = 25 Dollar Coin = 100)
// Product is what is for sale. A value: two equal products are interchangeable.type Product struct { Name string PriceCents int}
// State has one method per thing that can happen to the machine. Each state decides what// it means right now.type State interface { Insert(m *VendingMachine, c Coin) Select(m *VendingMachine, code string) Cancel(m *VendingMachine) Dispensed(m *VendingMachine, ok bool)}
// notNow holds the defaults: drop the coin back out, ask the customer to wait, ignore the// rest. States embed it and write only the events they handle.type notNow struct{}
func (notNow) Select(m *VendingMachine, code string) { m.hw.Show("Please wait") }
func (notNow) Cancel(m *VendingMachine) {}
func (notNow) Dispensed(m *VendingMachine, ok bool) {}
// idle is waiting for a customer. A coin starts a sale; a button just shows the price.type idle struct{ notNow }
func (idle) Select(m *VendingMachine, code string) { if s := m.slots[code]; s != nil { m.hw.Show(fmt.Sprintf("Price %d", s.Product.PriceCents)) } else { m.hw.Show("No such item") }}
// hasMoney is the only state where a sale can start, so every sale rule lives here.type hasMoney struct{ notNow }
// dispensing: the motor is running. Too late to cancel; coins drop straight back out.// Only the motor's report moves the machine on.type dispensing struct{ notNow }
// CoinBox is a bag of coins counted by kind. The escrow, the cash box and a handful of// change are all one.type CoinBox map[Coin]int
func (b CoinBox) AddAll(o CoinBox) { for c, n := range o { b[c] += n }}
func (b CoinBox) RemoveAll(o CoinBox) { for c, n := range o { b[c] -= n }}
func (b CoinBox) Total() int { t := 0 for c, n := range b { t += int(c) * n } return t}
// List returns the coins smallest first, so output is the same every run.func (b CoinBox) List() []Coin { var out []Coin for _, c := range allCoins { for range b[c] { out = append(out, c) } } return out}
var allCoins = []Coin{Nickel, Dime, Quarter, Dollar} // smallest first
// ChangeMaker pays out an amount from the coins on hand; ok=false means it can't be done exactly.type ChangeMaker interface { Make(cents int, available CoinBox) (change CoinBox, ok bool)}
// GreedyChange takes the 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.type GreedyChange struct{}
func (GreedyChange) Make(cents int, available CoinBox) (CoinBox, bool) { out := CoinBox{} for i := len(allCoins) - 1; i >= 0; i-- { c := allCoins[i] n := min(cents/int(c), available[c]) if n > 0 { out[c] = n } cents -= n * int(c) } return out, cents == 0}
// BacktrackingChange is greedy first, but when the rest can't be made it backs off one big// coin and tries again. It 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.type BacktrackingChange struct{}
func (BacktrackingChange) Make(cents int, available CoinBox) (CoinBox, bool) { out := CoinBox{} var fill func(i, cents int) bool fill = func(i, cents int) bool { if cents == 0 { return true } if i < 0 { return false // ran out of coin kinds with money still owed } c := allCoins[i] for n := min(cents/int(c), available[c]); n >= 0; n-- { if fill(i-1, cents-n*int(c)) { if n > 0 { out[c] = n } return true } } return false } return out, fill(len(allCoins)-1, cents)}
// Hardware is 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.type Hardware interface { StartDispense(slotCode string) // returns at once; the motor reports back via Dispensed(ok) ReturnCoins(coins []Coin) // the very coins, not just an amount Show(message string)}
// A coin the machine can't take now drops back out as that same coin.func (notNow) Insert(m *VendingMachine, c Coin) { m.hw.ReturnCoins([]Coin{c}) }
func (idle) Insert(m *VendingMachine, c Coin) { m.escrow[c]++ // held apart from the cash box until the sale completes m.state = hasMoney{} m.hw.Show(fmt.Sprintf("Credit %d", m.escrow.Total()))}
func (hasMoney) Insert(m *VendingMachine, c Coin) { m.escrow[c]++ m.hw.Show(fmt.Sprintf("Credit %d", m.escrow.Total()))}
// Select checks stock, credit and change before anything moves.func (hasMoney) Select(m *VendingMachine, code string) { s := m.slots[code] credit := m.escrow.Total() switch { case s == nil: m.hw.Show("No such item") return case !s.Available(): m.hw.Show("Sold out") return case credit < s.Product.PriceCents: m.hw.Show(fmt.Sprintf("Insert %d more", s.Product.PriceCents-credit)) 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. available := CoinBox{} available.AddAll(m.cash) available.AddAll(m.escrow) change, ok := m.changeMaker.Make(credit-s.Product.PriceCents, available) if !ok { m.hw.Show("Exact change only") // credit kept; pick again or cancel return } m.pending, m.pendingChange = s, change m.state = dispensing{} m.hw.StartDispense(code)}
// Cancel returns the very coins the customer put in, not "the same amount".func (hasMoney) Cancel(m *VendingMachine) { m.hw.ReturnCoins(m.escrow.List()) m.escrow = CoinBox{} m.state = idle{}}
// VendingMachine is what the keypad and coin slot call; it hands each event to the current// state. The states hold no data of their own, so they are empty structs.type VendingMachine struct { hw Hardware changeMaker ChangeMaker // injected: greedy, backtracking, fewest-coins slots map[string]*Slot cash CoinBox // the machine's money, used for change escrow CoinBox // this customer's coins, returned as-is on cancel pending *Slot pendingChange CoinBox // decided before the motor starts state State}
func NewVendingMachine(slots []*Slot, float CoinBox, cm ChangeMaker, hw Hardware) *VendingMachine { m := &VendingMachine{hw: hw, changeMaker: cm, slots: map[string]*Slot{}, cash: CoinBox{}, escrow: CoinBox{}, state: idle{}} m.cash.AddAll(float) // the coins the operator loads for change for _, s := range slots { m.slots[s.Code] = s } return m}
// Slot is one coil of one product, known by its keypad code, like "B4".type Slot struct { Code string Product Product count int jammed bool}
// Available treats a jammed coil as empty until the operator clears it.func (s *Slot) Available() bool { return s.count > 0 && !s.jammed }
func (s *Slot) jam() { s.jammed = true }
func (s *Slot) ClearJam() { s.jammed = false }
// Dispensed gets ok 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.func (dispensing) Dispensed(m *VendingMachine, ok bool) { 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.hw.ReturnCoins(m.pendingChange.List()) } 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.hw.ReturnCoins(m.escrow.List()) m.hw.Show("Sorry, could not dispense. Coins returned.") } m.escrow, m.pending, m.pendingChange = CoinBox{}, nil, nil m.state = idle{}}
// Event is everything that can happen to the machine, as data. Hardware goroutines create// these; only the loop goroutine acts on them.type Event interface{ isEvent() }
type CoinInserted struct{ Coin Coin }
type Selected struct{ Code string }
type Cancelled struct{}
type Dispensed struct{ OK bool }
func (CoinInserted) isEvent() {}
func (Selected) isEvent() {}
func (Cancelled) isEvent() {}
func (Dispensed) isEvent() {}
// Run is the one goroutine that owns the machine. The coin acceptor, the keypad and the// drop sensor each send on the channel, so two events are never handled at once and the// states need no locks at all. It returns when the channel is closed.func (m *VendingMachine) Run(events <-chan Event) { for e := range events { // strictly in arrival order m.handle(e) }}
func (m *VendingMachine) handle(e Event) { // One bad event must not stop the machine; log it and take the next. defer func() { if bug := recover(); bug != nil { log.Printf("event %#v failed: %v", e, bug) } }() switch e := e.(type) { case CoinInserted: m.Insert(e.Coin) case Selected: m.Select(e.Code) case Cancelled: m.Cancel() case Dispensed: m.Dispensed(e.OK) }}
func (s *Slot) take() { s.count-- }
// Every event goes to the current state. There is no "if state ==" anywhere in this type.func (m *VendingMachine) Insert(c Coin) { m.state.Insert(m, c) }
func (m *VendingMachine) Select(code string) { m.state.Select(m, code) }
func (m *VendingMachine) Cancel() { m.state.Cancel(m) }
func (m *VendingMachine) Dispensed(ok bool) { m.state.Dispensed(m, ok) }// The complete design, built from every stage on this page.
// The coins the machine accepts, each with its value. Money is integer cents, never a float.const Coin = { NICKEL: 5, DIME: 10, QUARTER: 25, DOLLAR: 100 } as const;
type Coin = (typeof Coin)[keyof typeof Coin];
// What is for sale. A value: two equal products are interchangeable.interface Product { readonly name: string; readonly priceCents: number;}
// One coil of one product, known by its keypad code, like "B4".class Slot { private jammed = false;
constructor( readonly code: string, readonly product: Product, private count: number, ) {}
// A jammed coil is as good as empty until the operator clears it. available(): boolean { return this.count > 0 && !this.jammed; } take(): void { this.count--; } jam(): void { this.jammed = true; } clearJam(): void { this.jammed = false; }}
// The keypad and coin slot call this; it hands each event to the current state.class VendingMachine { readonly slots: Map<string, Slot>; readonly cash = new CoinBox(); // the machine's money, used for change readonly escrow = new CoinBox(); // this customer's coins, returned as-is on cancel pending: Slot | null = null; pendingChange: CoinBox | null = null; // decided before the motor starts state: MachineState = IDLE;
constructor( slots: Slot[], float: CoinBox, // the coins the operator loads for change readonly changeMaker: ChangeMaker, // injected: greedy, backtracking, fewest-coins readonly hardware: Hardware, ) { this.slots = new Map(slots.map((s) => [s.code, s])); this.cash.addAll(float); }
insert(c: Coin): void { this.state.insert(this, c); } select(code: string): void { this.state.select(this, code); } cancel(): void { this.state.cancel(this); } dispensed(ok: boolean): void { this.state.dispensed(this, ok); }}
// 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 { startDispense(slotCode: string): void; // returns at once; the motor reports back via dispensed(ok) returnCoins(coins: Coin[]): void; // the very coins, not just an amount show(message: string): void;}
// One method per thing that can happen to the machine. Each state decides what it means// right now.interface MachineState { insert(m: VendingMachine, c: Coin): void; select(m: VendingMachine, code: string): void; cancel(m: VendingMachine): void; dispensed(m: VendingMachine, ok: boolean): void;}
// The "not now" answer to every event. Each state extends it and writes only the events it// handles.abstract class NotNow implements MachineState { insert(m: VendingMachine, c: Coin): void { m.hardware.returnCoins([c]); // a coin the machine can't take drops back out as itself } select(m: VendingMachine, code: string): void { m.hardware.show('Please wait'); } cancel(m: VendingMachine): void {} dispensed(m: VendingMachine, ok: boolean): void {}}
// Waiting for a customer. A button press just shows the price.class Idle extends NotNow { insert(m: VendingMachine, c: Coin): void { m.escrow.add(c); // held apart from the cash box until the sale completes m.state = HAS_MONEY; m.hardware.show(`Credit ${m.escrow.total()}`); } select(m: VendingMachine, code: string): void { const s = m.slots.get(code); m.hardware.show(s ? `Price ${s.product.priceCents}` : 'No such item'); }}
// Holding the customer's coins. A selection checks stock, credit and change before anything moves.class HasMoney extends NotNow { insert(m: VendingMachine, c: Coin): void { m.escrow.add(c); m.hardware.show(`Credit ${m.escrow.total()}`); } select(m: VendingMachine, code: string): void { const s = m.slots.get(code); if (!s) return m.hardware.show('No such item'); if (!s.available()) return m.hardware.show('Sold out'); const credit = m.escrow.total(); const price = s.product.priceCents; if (credit < price) return m.hardware.show(`Insert ${price - credit} more`); // 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. const available = new CoinBox(); available.addAll(m.cash); available.addAll(m.escrow); const change = m.changeMaker.make(credit - price, available); if (!change) return m.hardware.show('Exact change only'); // credit kept; pick again or cancel m.pending = s; m.pendingChange = change; m.state = DISPENSING; m.hardware.startDispense(code); } // Cancel returns the very coins the customer put in, not "the same amount". cancel(m: VendingMachine): void { m.hardware.returnCoins(m.escrow.toList()); m.escrow.clear(); m.state = IDLE; }}
// The motor is turning. Coins and buttons get the "not now" defaults until the drop sensor// reports back.class Dispensing extends NotNow { // 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. dispensed(m: VendingMachine, ok: boolean): void { 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 = m.pendingChange = null; m.state = IDLE; }}
// States hold no data, so one instance of each is shared. The data lives in the machine.const IDLE = new Idle();
const HAS_MONEY = new HasMoney();
const DISPENSING = new Dispensing();
// A bag of coins counted by kind. The escrow, the cash box and a handful of change are all one.class CoinBox { private readonly counts = new Map<Coin, number>();
static of(entries: [Coin, number][]): CoinBox { const b = new CoinBox(); for (const [c, n] of entries) b.add(c, n); return b; }
add(c: Coin, n = 1): void { this.counts.set(c, this.count(c) + n); } addAll(o: CoinBox): void { for (const [c, n] of o.counts) this.add(c, n); } removeAll(o: CoinBox): void { for (const [c, n] of o.counts) this.add(c, -n); } count(c: Coin): number { return this.counts.get(c) ?? 0; } total(): number { let t = 0; for (const [c, n] of this.counts) t += c * n; return t; } clear(): void { this.counts.clear(); } toList(): Coin[] { return [...this.counts].flatMap(([c, n]) => Array<Coin>(n).fill(c)); }}
const BIGGEST_FIRST: Coin[] = [Coin.DOLLAR, Coin.QUARTER, Coin.DIME, Coin.NICKEL];
// How to pay out an amount from the coins on hand. null means it can't be done exactly.interface ChangeMaker { make(cents: number, available: CoinBox): CoinBox | null;}
// 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 { make(cents: number, available: CoinBox): CoinBox | null { const out = new CoinBox(); for (const c of BIGGEST_FIRST) { const n = Math.min(Math.floor(cents / c), available.count(c)); if (n > 0) out.add(c, n); cents -= n * c; } return cents === 0 ? out : null; }}
// 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 { make(cents: number, available: CoinBox): CoinBox | null { const out = new CoinBox(); const fill = (i: number, cents: number): boolean => { if (cents === 0) return true; if (i === BIGGEST_FIRST.length) return false; // ran out of coin kinds with money owed const c = BIGGEST_FIRST[i]; for (let n = Math.min(Math.floor(cents / c), available.count(c)); n >= 0; n--) { if (fill(i + 1, cents - n * c)) { if (n > 0) out.add(c, n); return true; } } return false; }; return fill(0, cents) ? out : null; }}
// Everything that can happen to the machine, as data.type MachineEvent = | { kind: 'coin'; coin: Coin } | { kind: 'select'; code: string } | { kind: 'cancel' } | { kind: 'dispensed'; ok: boolean };
// Node never runs two handlers at the same time, so there is no thread race to fix. What it// does have is re-entry: a handler can trigger another event while it is still running (a// fast motor reporting back from inside startDispense). The queue makes that event wait// until the current one has finished, so a state change is never interrupted halfway.class MachineLoop { private readonly queue: MachineEvent[] = []; private running = false;
constructor(private readonly machine: VendingMachine) {}
post(e: MachineEvent): void { this.queue.push(e); if (this.running) return; // the loop below will reach it this.running = true; try { for (let next = this.queue.shift(); next; next = this.queue.shift()) this.handle(next); } finally { this.running = false; } }
private handle(e: MachineEvent): void { try { switch (e.kind) { case 'coin': return this.machine.insert(e.coin); case 'select': return this.machine.select(e.code); case 'cancel': return this.machine.cancel(); case 'dispensed': return this.machine.dispensed(e.ok); } } catch (bug) { // One bad event must not stop the machine; log it and take the next. console.error('event failed', e, bug); } }}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
Hardwareinterface. 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 behindChangeMaker(Strategy). And to the devices, the machine is a simple front door with four events (a facade).
Stress it
Section titled “Stress it”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?
Cancel and select at the same moment
Section titled “Cancel and select at the same moment”The coin acceptor, the keypad and the drop sensor are separate devices with their own threads. What can go wrong?
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.
- Both threads see the state as
HasMoney. - The select thread sets aside change and starts the motor.
- The cancel thread returns the escrow and empties it.
- 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 |
// 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); } } }}import queueimport threadingfrom dataclasses import dataclass
# Everything that can happen to the machine, as data. Hardware threads create these;# only the loop thread acts on them.@dataclass(frozen=True)class CoinInserted: coin: Coin
@dataclass(frozen=True)class Selected: code: str
@dataclass(frozen=True)class Cancelled: pass
@dataclass(frozen=True)class Dispensed: ok: bool
Event = CoinInserted | Selected | Cancelled | Dispensed
# 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: def __init__(self, machine: VendingMachine): self._machine = machine self._events: queue.Queue[Event | None] = queue.Queue()
# Safe from any thread, and never blocks the caller: the coin acceptor can't wait. def post(self, e: Event) -> None: self._events.put(e)
def stop(self) -> None: self._events.put(None) # handled after everything already queued
def run(self) -> None: while (e := self._events.get()) is not None: # strictly in arrival order try: match e: case CoinInserted(coin): self._machine.insert(coin) case Selected(code): self._machine.select(code) case Cancelled(): self._machine.cancel() case Dispensed(ok): self._machine.dispensed(ok) except Exception as bug: # One bad event must not stop the machine; log it and take the next. print(f"event {e} failed: {bug!r}")// Everything that can happen to the machine, as data. Hardware threads create these;// only the loop acts on them.public abstract record Event;public sealed record CoinInserted(Coin Coin) : Event;public sealed record Selected(string Code) : Event;public sealed record Cancelled : Event;public sealed record Dispensed(bool Ok) : Event;
// One loop 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.public sealed class MachineLoop(VendingMachine machine){ // Unbounded and single-reader: posting never blocks, and only RunAsync reads. private readonly Channel<Event> _events = Channel.CreateUnbounded<Event>(new UnboundedChannelOptions { SingleReader = true });
// Safe from any thread, and never blocks the caller: the coin acceptor can't wait. public void Post(Event e) => _events.Writer.TryWrite(e);
public void Stop() => _events.Writer.Complete(); // handled after everything already queued
public async Task RunAsync() { await foreach (var e in _events.Reader.ReadAllAsync()) // strictly in arrival order { try { switch (e) { case CoinInserted c: machine.Insert(c.Coin); break; case Selected s: machine.Select(s.Code); break; case Cancelled: machine.Cancel(); break; case Dispensed d: machine.Dispensed(d.Ok); break; } } catch (Exception bug) { // One bad event must not stop the machine; log it and take the next. Console.Error.WriteLine($"event {e} failed: {bug.Message}"); } } }}// Event is everything that can happen to the machine, as data. Hardware goroutines create// these; only the loop goroutine acts on them.type Event interface{ isEvent() }
type CoinInserted struct{ Coin Coin }type Selected struct{ Code string }type Cancelled struct{}type Dispensed struct{ OK bool }
func (CoinInserted) isEvent() {}func (Selected) isEvent() {}func (Cancelled) isEvent() {}func (Dispensed) isEvent() {}
// Run is the one goroutine that owns the machine. The coin acceptor, the keypad and the// drop sensor each send on the channel, so two events are never handled at once and the// states need no locks at all. It returns when the channel is closed.func (m *VendingMachine) Run(events <-chan Event) { for e := range events { // strictly in arrival order m.handle(e) }}
func (m *VendingMachine) handle(e Event) { // One bad event must not stop the machine; log it and take the next. defer func() { if bug := recover(); bug != nil { log.Printf("event %#v failed: %v", e, bug) } }() switch e := e.(type) { case CoinInserted: m.Insert(e.Coin) case Selected: m.Select(e.Code) case Cancelled: m.Cancel() case Dispensed: m.Dispensed(e.OK) }}// Everything that can happen to the machine, as data.type MachineEvent = | { kind: 'coin'; coin: Coin } | { kind: 'select'; code: string } | { kind: 'cancel' } | { kind: 'dispensed'; ok: boolean };
// Node never runs two handlers at the same time, so there is no thread race to fix. What it// does have is re-entry: a handler can trigger another event while it is still running (a// fast motor reporting back from inside startDispense). The queue makes that event wait// until the current one has finished, so a state change is never interrupted halfway.class MachineLoop { private readonly queue: MachineEvent[] = []; private running = false;
constructor(private readonly machine: VendingMachine) {}
post(e: MachineEvent): void { this.queue.push(e); if (this.running) return; // the loop below will reach it this.running = true; try { for (let next = this.queue.shift(); next; next = this.queue.shift()) this.handle(next); } finally { this.running = false; } }
private handle(e: MachineEvent): void { try { switch (e.kind) { case 'coin': return this.machine.insert(e.coin); case 'select': return this.machine.select(e.code); case 'cancel': return this.machine.cancel(); case 'dispensed': return this.machine.dispensed(e.ok); } } catch (bug) { // One bad event must not stop the machine; log it and take the next. console.error('event failed', e, 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.
How you’d prove it works
Section titled “How you’d prove it works”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
// 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);}
@Testvoid 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());}
@Testvoid 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}
@Testvoid 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}
@Testvoid 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}
@Testvoid 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();}from concurrent.futures import ThreadPoolExecutor
# Records what the machine did instead of moving motors. The drop sensor is the test itself.class FakeHardware: def __init__(self): self.dispensed: list[str] = [] self.shown: list[str] = [] self.returned: list[Coin] = []
def start_dispense(self, code: str) -> None: self.dispensed.append(code)
def return_coins(self, coins: list[Coin]) -> None: self.returned.extend(coins)
def show(self, message: str) -> None: self.shown.append(message)
def machine(float_: CoinBox, hw: FakeHardware) -> VendingMachine: # One slot: chips at 65 cents, two bags. return VendingMachine([Slot("A1", Product("Chips", 65), 2)], float_, BacktrackingChange(), hw)
def test_greedy_gives_up_where_backtracking_finds_change(): box = CoinBox({Coin.QUARTER: 1, Coin.DIME: 3}) # no nickels assert GreedyChange().make(30, box) is None # takes the quarter, stuck at 5 assert BacktrackingChange().make(30, box) == CoinBox({Coin.DIME: 3})
def test_no_change_means_no_sale_and_the_credit_is_kept(): hw = FakeHardware() m = machine(CoinBox(), hw) # empty cash box m.insert(Coin.DOLLAR) m.select("A1") # owes 35, holds nothing to pay it with assert hw.shown[-1] == "Exact change only" and not hw.dispensed m.cancel() assert hw.returned == [Coin.DOLLAR] # the very coin they put in
def test_cancel_is_refused_once_the_motor_runs(): hw = FakeHardware() m = machine(CoinBox({Coin.DIME: 1, Coin.QUARTER: 1}), hw) m.insert(Coin.DOLLAR) m.select("A1") m.cancel() # too late: the bag is already moving assert hw.returned == [] m.dispensed(True) assert CoinBox(hw.returned).total_cents() == 35 # change only after the drop
def test_a_jam_refunds_the_customer_and_retires_the_slot(): hw = FakeHardware() m = machine(CoinBox({Coin.DIME: 1, Coin.QUARTER: 1}), hw) for _ in range(3): m.insert(Coin.QUARTER) m.select("A1") m.dispensed(False) assert hw.returned == [Coin.QUARTER] * 3 m.insert(Coin.DOLLAR) m.select("A1") assert hw.shown[-1] == "Sold out" # jammed counts as empty
def test_coins_posted_from_many_threads_are_never_lost(): hw = FakeHardware() loop = MachineLoop(machine(CoinBox(), hw)) owner = threading.Thread(target=loop.run) owner.start() with ThreadPoolExecutor(max_workers=8) as acceptors: for _ in range(800): acceptors.submit(loop.post, CoinInserted(Coin.NICKEL)) loop.post(Cancelled()) # returns everything inserted so far loop.stop() owner.join() assert CoinBox(hw.returned).total_cents() == 800 * 5// Records what the machine did instead of moving motors. The drop sensor is the test itself.private sealed class FakeHardware : IHardware{ public readonly List<string> Dispensed = [], Shown = []; public readonly List<Coin> Returned = []; public void StartDispense(string code) => Dispensed.Add(code); public void ReturnCoins(IReadOnlyList<Coin> coins) { lock (Returned) Returned.AddRange(coins); } public void Show(string message) => Shown.Add(message);}
private static CoinBox Coins(params Coin[] cs){ var b = new CoinBox(); foreach (var c in cs) b.Add(c); return b;}
private readonly FakeHardware _hw = new();
// One slot: chips at 65 cents, two bags.private VendingMachine Machine(CoinBox @float) => new([new Slot("A1", new Product("Chips", 65), 2)], @float, new BacktrackingChange(), _hw);
[Fact]public void GreedyGivesUpWhereBacktrackingFindsChange(){ var box = Coins(Coin.Quarter, Coin.Dime, Coin.Dime, Coin.Dime); // no nickels Assert.Null(new GreedyChange().Make(30, box)); // takes the quarter, stuck at 5 Assert.Equal(new[] { Coin.Dime, Coin.Dime, Coin.Dime }, new BacktrackingChange().Make(30, box)!.ToList());}
[Fact]public void NoChangeMeansNoSaleAndTheCreditIsKept(){ var m = Machine(new CoinBox()); // empty cash box m.Insert(Coin.Dollar); m.Select("A1"); // owes 35, holds nothing to pay it with Assert.Equal("Exact change only", _hw.Shown[^1]); m.Cancel(); Assert.Equal(new[] { Coin.Dollar }, _hw.Returned); // the very coin they put in}
[Fact]public void AJamRefundsTheCustomerAndRetiresTheSlot(){ var m = Machine(Coins(Coin.Dime, Coin.Quarter)); for (int i = 0; i < 3; i++) m.Insert(Coin.Quarter); m.Select("A1"); m.Dispensed(false); Assert.Equal(75, _hw.Returned.Sum(c => (int)c)); m.Insert(Coin.Dollar); m.Select("A1"); Assert.Equal("Sold out", _hw.Shown[^1]); // jammed counts as empty}
[Fact]public async Task CoinsPostedFromManyThreadsAreNeverLost(){ var loop = new MachineLoop(Machine(new CoinBox())); var owner = loop.RunAsync(); await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => Task.Run(() => { for (int i = 0; i < 100; i++) loop.Post(new CoinInserted(Coin.Nickel)); }))); loop.Post(new Cancelled()); // returns everything inserted so far loop.Stop(); await owner; Assert.Equal(800 * 5, _hw.Returned.Sum(c => (int)c));}// fakeHardware records what the machine did instead of moving motors.// The drop sensor is the test itself.type fakeHardware struct { dispensed, shown []string returned []Coin}
func (f *fakeHardware) StartDispense(code string) { f.dispensed = append(f.dispensed, code) }func (f *fakeHardware) ReturnCoins(coins []Coin) { f.returned = append(f.returned, coins...) }func (f *fakeHardware) Show(message string) { f.shown = append(f.shown, message) }
func total(coins []Coin) (t int) { for _, c := range coins { t += int(c) } return t}
// newMachine has one slot: chips at 65 cents, two bags.func newMachine(float CoinBox, hw Hardware) *VendingMachine { return NewVendingMachine([]*Slot{{Code: "A1", Product: Product{"Chips", 65}, count: 2}}, float, BacktrackingChange{}, hw)}
func TestGreedyGivesUpWhereBacktrackingFindsChange(t *testing.T) { box := CoinBox{Quarter: 1, Dime: 3} // no nickels if _, ok := (GreedyChange{}).Make(30, box); ok { t.Fatal("greedy should get stuck after taking the quarter") } change, ok := (BacktrackingChange{}).Make(30, box) if !ok || change[Dime] != 3 || len(change) != 1 { t.Fatalf("got %v, want three dimes", change) }}
func TestNoChangeMeansNoSaleAndTheCreditIsKept(t *testing.T) { hw := &fakeHardware{} m := newMachine(CoinBox{}, hw) // empty cash box m.Insert(Dollar) m.Select("A1") // owes 35, holds nothing to pay it with if hw.shown[len(hw.shown)-1] != "Exact change only" || len(hw.dispensed) != 0 { t.Fatalf("shown %v, dispensed %v", hw.shown, hw.dispensed) } m.Cancel() if len(hw.returned) != 1 || hw.returned[0] != Dollar { t.Fatalf("returned %v, want the same dollar coin", hw.returned) }}
func TestAJamRefundsTheCustomerAndRetiresTheSlot(t *testing.T) { hw := &fakeHardware{} m := newMachine(CoinBox{Dime: 1, Quarter: 1}, hw) for range 3 { m.Insert(Quarter) } m.Select("A1") m.Dispensed(false) if total(hw.returned) != 75 { t.Fatalf("refunded %v", hw.returned) } m.Insert(Dollar) m.Select("A1") if hw.shown[len(hw.shown)-1] != "Sold out" { // jammed counts as empty t.Fatalf("shown %v", hw.shown) }}
// Run with: go test -race ./... The race detector flags any state touched off the loop.func TestCoinsSentFromManyGoroutinesAreNeverLost(t *testing.T) { hw := &fakeHardware{} m := newMachine(CoinBox{}, hw) events := make(chan Event, 64) done := make(chan struct{}) go func() { m.Run(events); close(done) }()
var acceptors sync.WaitGroup for range 8 { acceptors.Add(1) go func() { defer acceptors.Done() for range 100 { events <- CoinInserted{Nickel} } }() } acceptors.Wait() events <- Cancelled{} // returns everything inserted so far close(events) <-done if total(hw.returned) != 800*5 { t.Fatalf("returned %d cents, want %d", total(hw.returned), 800*5) }}import { test } from 'node:test';import assert from 'node:assert/strict';
// Records what the machine did instead of moving motors. The drop sensor is the test itself.class FakeHardware implements Hardware { dispensed: string[] = []; shown: string[] = []; returned: Coin[] = []; onDispense: (code: string) => void = () => {}; startDispense(code: string): void { this.dispensed.push(code); this.onDispense(code); } returnCoins(coins: Coin[]): void { this.returned.push(...coins); } show(message: string): void { this.shown.push(message); }}
// One slot: chips at 65 cents, two bags.const machine = (float: CoinBox, hw: Hardware) => new VendingMachine([new Slot('A1', { name: 'Chips', priceCents: 65 }, 2)], float, new BacktrackingChange(), hw);const sum = (coins: Coin[]) => coins.reduce((a, c) => a + c, 0);
test('greedy gives up where backtracking finds change', () => { const box = CoinBox.of([[Coin.QUARTER, 1], [Coin.DIME, 3]]); // no nickels assert.equal(new GreedyChange().make(30, box), null); // takes the quarter, stuck at 5 assert.deepEqual(new BacktrackingChange().make(30, box)?.toList(), [Coin.DIME, Coin.DIME, Coin.DIME]);});
test('no change means no sale, and the credit is kept', () => { const hw = new FakeHardware(); const m = machine(new CoinBox(), hw); // empty cash box m.insert(Coin.DOLLAR); m.select('A1'); // owes 35, holds nothing to pay it with assert.equal(hw.shown.at(-1), 'Exact change only'); m.cancel(); assert.deepEqual(hw.returned, [Coin.DOLLAR]); // the very coin they put in});
test('a jam refunds the customer and retires the slot', () => { const hw = new FakeHardware(); const m = machine(CoinBox.of([[Coin.DIME, 1], [Coin.QUARTER, 1]]), hw); for (let i = 0; i < 3; i++) m.insert(Coin.QUARTER); m.select('A1'); m.dispensed(false); assert.equal(sum(hw.returned), 75); m.insert(Coin.DOLLAR); m.select('A1'); assert.equal(hw.shown.at(-1), 'Sold out'); // jammed counts as empty});
test('a motor that reports back instantly waits its turn', () => { const hw = new FakeHardware(); const m = machine(CoinBox.of([[Coin.DIME, 1], [Coin.QUARTER, 1]]), hw); const loop = new MachineLoop(m); hw.onDispense = () => loop.post({ kind: 'dispensed', ok: true }); // re-enters from inside select loop.post({ kind: 'coin', coin: Coin.DOLLAR }); loop.post({ kind: 'select', code: 'A1' }); assert.equal(sum(hw.returned), 35); // the sale completed, in order assert.equal(m.state, IDLE);});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 |
Wrap up
Section titled “Wrap up”The decisions, and what each one costs
Section titled “The decisions, and what each one costs”| 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.
How to spend 45 minutes
Section titled “How to spend 45 minutes”- 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.selectand 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.
Follow-up questions
Section titled “Follow-up questions”We want card payments, and the operator wants an alert when a slot jams or change runs low.
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:
- A
Paymentinterface. 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”. - An
Authorizingstate. When the customer taps a card and picks an item, the machine asks the bank to approve the amount and moves toAuthorizing. The bank’s answer comes back later, as an event on the same queue as everything else. - Two new events.
approvedmoves the machine toDispensing, exactly like a coin sale.declinedshows “Card declined” and goes back toIdle.
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:
- Name the events.
SlotJammed(slot),SoldOut(slot),LowChange(coin, count). Each is a small record with the facts, and nothing else. - Send them from one place each.
Dispensing.dispensedsendsSlotJammedwhen the drop fails. Finishing a sale sendsSoldOutwhen a slot reaches zero, andLowChangewhen a coin count drops below the operator’s limit. - Add listeners when the machine is built. An
OperatorAlertslistener sends a text message. ATelemetrylistener 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.
Further reading
Section titled “Further reading”- State pattern, Strategy pattern, Adapter and facade
- Design a parking lot, where an enum and a table beat state classes
- Design an ATM, the same commit-on-success idea with a bank in the loop
- Wikipedia: Change-making problem, on when greedy is optimal and when it isn’t
- W3C SCXML, the statechart standard behind most state machine libraries
- Go blog: Share memory by communicating, the idea behind the event loop
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.