Vending machine
A state machine in practice, with inventory, money handling and exact change.
What you will learn
- 01The problem
- 02Step 1: requirements
- 03Step 2: why a state machine
- 04Step 3: entities and responsibilities
- 05Step 4: money must be exact
- 06Step 5: making change
- 07Step 6: the code
- 08Step 7: failure and safety
- 09Step 8: tests worth writing
- 10Step 9: extensions
- 11Mistakes to avoid
The problem
Design a vending machine. A customer inserts money, chooses a product, and receives the product and any change. The machine holds a limited stock of products and coins. This problem is the standard way to practice the state pattern, because the same button does different things depending on what has already happened.
Step 1: requirements
- Products have a code, a price and a quantity.
- A customer inserts coins, selects a product, and receives the product plus change.
- The customer can cancel before selecting and get all money back.
- Insufficient money, an out-of-stock product, and inability to make exact change are all refused with a clear reason, and no money is lost.
- A technician can refill stock and coins.
- Out of scope: the hardware, card readers, network telemetry.
- Rules that matter: the machine never keeps money without delivering, and never delivers without being paid.
Step 2: why a state machine
Try the plain approach first. A selectProduct method that checks if (balance === 0), if (dispensing), if (outOfService) and so on collects flags, and every new feature multiplies the combinations. A state machine replaces flags with a small named set of states and a table of which events are legal in each.
States and events here:
- Idle: no money. Inserting a coin moves to HasMoney. Selecting is an error.
- HasMoney: money is held. More coins add to the balance. Selecting a valid, affordable product moves to Dispensing. Cancel refunds and returns to Idle.
- Dispensing: the machine is delivering. All input is refused until it finishes, then it returns to Idle.
- OutOfService: entered on a fault. Everything is refused.
Swipe sideways to pan · drag a box to move it
Swipe sideways to pan · drag a box to move it
Swipe sideways to pan · drag a box to move it
Swipe sideways to pan · drag a box to move it
Every illegal transition is simply absent. The design question "what if the user presses select with no money" has an answer in exactly one place.
Step 3: entities and responsibilities
- Machine: the context. Holds the balance, the current state, and the inventory, and delegates each event to the state.
- State classes: each decides what an event means now and which state follows.
- Inventory: products and quantities, with
has,takeandrefill. - CoinBox: counts per denomination, able to compute change.
By Expert, the inventory knows stock and the coin box knows the coins, so neither logic leaks into the states.
Step 4: money must be exact
Represent all money as integers in the smallest unit, such as paise, never as floating point. Adding 0.1 and 0.2 in floating point does not give 0.3, and a machine that loses a paisa per sale is wrong.
Step 5: making change
Given an amount to return and the coins on hand, the greedy method takes the largest coin that fits, repeatedly.
function makeChange(amount: number, coins: Map<number, number>): Map<number, number> | null {
const out = new Map<number, number>();
for (const d of [...coins.keys()].sort((a, b) => b - a)) {
const use = Math.min(Math.floor(amount / d), coins.get(d)!);
if (use > 0) { out.set(d, use); amount -= use * d; }
}
return amount === 0 ? out : null; // null means exact change is impossible
}
Greedy is correct for ordinary currencies such as 1, 2, 5 and 10, where each coin is at least twice the one below. It can fail for unusual sets: with coins 1, 3 and 4, making 6 greedily gives 4 + 1 + 1 (three coins) when 3 + 3 (two coins) is best. A dynamic programming solution finds the fewest coins for any set at higher cost. Because coin counts are limited, the machine may also be unable to make change at all. It must check this before it takes the sale, and refuse otherwise.
Step 6: the code
interface State {
insert(m: Machine, coin: number): void;
select(m: Machine, code: string): void;
cancel(m: Machine): void;
}
class Machine {
balance = 0;
state: State = new Idle();
constructor(readonly stock: Map<string, { price: number; qty: number }>, readonly coins: Map<number, number>) {}
insert(c: number) { this.state.insert(this, c); }
select(code: string) { this.state.select(this, code); }
cancel() { this.state.cancel(this); }
}
class Idle implements State {
insert(m: Machine, coin: number) { m.balance += coin; m.state = new HasMoney(); }
select(): void { throw new Error("insert money first"); }
cancel(): void { /* nothing to refund */ }
}
class HasMoney implements State {
insert(m: Machine, coin: number) { m.balance += coin; }
select(m: Machine, code: string) {
const item = m.stock.get(code);
if (!item || item.qty === 0) throw new Error("unavailable");
if (m.balance < item.price) throw new Error("insufficient money");
const change = makeChange(m.balance - item.price, m.coins);
if (!change) throw new Error("cannot give exact change");
m.state = new Dispensing();
item.qty--; // commit only after every check passed
m.balance = 0;
m.state = new Idle();
}
cancel(m: Machine) { m.balance = 0; m.state = new Idle(); }
}
All checks happen before any change, so a refusal leaves the machine untouched. A real machine would model Dispensing as a state that waits for a hardware confirmation. Here it is collapsed to keep the example short.
Step 7: failure and safety
The danger is a fault halfway: the motor jams after payment. The rule "never keep money without delivering" means the machine must either retry, refund, or move to OutOfService with the transaction recorded. Writing the sale as one unit, take payment, deliver, update stock, and undoing it on failure, is the same idea as a database transaction. Log every transition so a technician can see what happened.
Step 8: tests worth writing
- Selecting with no money throws and changes nothing.
- Exact payment gives the product and no change.
- Overpayment gives the right coins back, including when the box lacks a large coin.
- When exact change is impossible, the sale is refused and money returned.
- Cancel after inserting returns the full balance.
- A sold-out product is refused.
- No path leaves the balance non-zero in Idle.
Because states are separate classes, each can be tested alone with a stubbed machine.
Step 9: extensions
- Card or wallet payment: a payment strategy replacing coin handling, with the state machine untouched.
- Notifications when stock is low: an observer on the inventory.
- Admin mode: a new state allowing refill and price changes.
- Promotions: a decorator on pricing.
- Many machines: a repository and event stream so a head office can monitor them.
Mistakes to avoid
- Flags such as
isPaidandisDispensinginstead of states. - Floating point money.
- Changing stock or balance before all checks pass.
- Letting states reach into each other's data.
- Forgetting that the machine can run out of coins.