Episode 9 — System Design / 9.6 — LLD Problem Solving
9.6.b — Vending Machine
Problem: Design a vending machine that accepts coins, lets the user select a product, dispenses it, and returns change. The machine transitions through well-defined states.
Table of Contents
- Requirements
- State Machine
- Entities and Enums
- Class Diagram
- Sequence Flow
- Complete Implementation
- Usage Walkthrough
- Edge Cases
- Key Takeaways
Requirements
Functional Requirements
- Machine holds an inventory of products, each with a name, price, and quantity.
- User inserts coins (1, 5, 10, 25 units).
- User selects a product by code (e.g., "A1", "B2").
- Machine dispenses the product if enough money is inserted and the item is in stock.
- Machine returns change (difference between inserted amount and price).
- User can cancel at any point and get a full refund.
Non-Functional Requirements
- Machine states must be explicit and transitions must be well-defined.
- Easy to add new states (e.g., maintenance mode).
Out of Scope
- Paper currency, card payments.
- Remote monitoring / IoT.
State Machine
┌──────────────┐
┌───────────│ IDLE │◄──────────────────────┐
│ └──────┬───────┘ │
│ │ insertCoin() │
│ ▼ │
│ ┌──────────────────┐ │
│ ┌─────►│ COIN_INSERTED │────── cancel() ────┘
│ │ └──────┬───────────┘ (refund all)
│ │ │ selectProduct()
│ │ ▼
│ │ ┌──────────────────┐
│ │ │ PRODUCT_SELECTED │────── cancel() ────┐
│ │ └──────┬───────────┘ (refund all) │
│ │ │ │
│ │ enough │ not enough │
│ │ money? │ money? │
│ │ │ │ │ │
│ │ │ ▼ │ │
│ │ │ "Insert │ │
│ │ │ more" │ │
│ │ │ │ │ │
│ │ │ └────┘ │
│ │ │ insertCoin() │
│ │ │ (back to COIN_INSERTED) │
│ │ ▼ │
│ │ ┌──────────────┐ │
│ │ │ DISPENSING │ │
│ │ └──────┬───────┘ │
│ │ │ dispense() │
│ │ │ returnChange() │
│ │ ▼ │
│ │ ┌──────┐ │
│ └─────│ IDLE │◄─────────────────────────────────┘
│ └──────┘
│
│ (machine created)
└── starts here
State Transition Table
| Current State | Action | Next State | Side Effect |
|---|---|---|---|
| IDLE | insertCoin(coin) | COIN_INSERTED | Add coin to balance |
| COIN_INSERTED | insertCoin(coin) | COIN_INSERTED | Add coin to balance |
| COIN_INSERTED | selectProduct(code) | PRODUCT_SELECTED | Validate product exists and is in stock |
| COIN_INSERTED | cancel() | IDLE | Refund all inserted coins |
| PRODUCT_SELECTED | dispense() | DISPENSING | Check balance >= price |
| PRODUCT_SELECTED | cancel() | IDLE | Refund all inserted coins |
| DISPENSING | (auto) | IDLE | Decrement inventory, return change |
Entities and Enums
┌──────────────────┐ ┌──────────────────┐
│ Coin │ │ MachineState │
│──────────────────│ │──────────────────│
│ PENNY = 1 │ │ IDLE │
│ NICKEL = 5 │ │ COIN_INSERTED │
│ DIME = 10 │ │ PRODUCT_SELECTED │
│ QUARTER = 25 │ │ DISPENSING │
└──────────────────┘ └──────────────────┘
Class Diagram
┌─────────────────────────────────────────────────────────────────────┐
│ VendingMachine │
│─────────────────────────────────────────────────────────────────────│
│ - inventory: Inventory │
│ - currentBalance: number │
│ - state: State (State pattern) │
│ - selectedProduct: Product | null │
│─────────────────────────────────────────────────────────────────────│
│ + insertCoin(coin): void │
│ + selectProduct(code): void │
│ + dispense(): { product, change } │
│ + cancel(): number (refund) │
│ + setState(state): void │
│ + getBalance(): number │
└──────────────┬──────────────────────────────┬───────────────────────┘
│ has-a │ has-a
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────────────┐
│ Inventory │ │ State (interface) │
│──────────────────────────│ │──────────────────────────────────│
│ - items: Map<code, Item> │ │ + insertCoin(machine, coin): void │
│──────────────────────────│ │ + selectProduct(machine, code) │
│ + addItem(code, product) │ │ + dispense(machine): object │
│ + getItem(code): Item │ │ + cancel(machine): number │
│ + isAvailable(code): bool │ └─────────┬──────────────────────────┘
│ + reduceStock(code): void │ │ implemented by
└──────────────────────────┘ ┌──────────┼──────────┬──────────────┐
▼ ▼ ▼ ▼
IdleState CoinInserted ProductSelected DispensingState
State State
┌──────────────────────────┐
│ Product │
│──────────────────────────│
│ - name: string │
│ - price: number │
│ - quantity: number │
└──────────────────────────┘
Sequence Flow
Happy Path: Insert Coins -> Select -> Dispense
User VendingMachine State Inventory
│ │ │ │
│── insertCoin(25)►│ │ │
│ │── insertCoin() ─►│ (IdleState) │
│ │ setState ──────│► CoinInserted │
│ │ balance = 25 │ │
│ │ │ │
│── insertCoin(25)►│ │ │
│ │── insertCoin() ─►│ (CoinInserted) │
│ │ balance = 50 │ │
│ │ │ │
│── select("A1") ─►│ │ │
│ │── selectProduct()►│ │
│ │ │── isAvailable? ──►│
│ │ │◄── yes ───────────│
│ │ setState ──────│► ProductSelected │
│ │ │ │
│── dispense() ───►│ │ │
│ │── dispense() ───►│ (ProductSelected) │
│ │ │── price <= bal? │
│ │ │ yes │
│ │ │── reduceStock() ──►│
│ │ setState ──────│► Dispensing │
│ │ │── return product + change
│ │ setState ──────│► Idle │
│◄── { product, │ │ │
│ change: 10 } │ │ │
Complete Implementation
Enums and Product
// ─── Enums ──────────────────────────────────────────────
const Coin = Object.freeze({
PENNY: 1,
NICKEL: 5,
DIME: 10,
QUARTER: 25,
});
const MachineState = Object.freeze({
IDLE: 'IDLE',
COIN_INSERTED: 'COIN_INSERTED',
PRODUCT_SELECTED: 'PRODUCT_SELECTED',
DISPENSING: 'DISPENSING',
});
Product and Inventory
// ─── Product ────────────────────────────────────────────
class Product {
constructor(name, price, quantity) {
this.name = name;
this.price = price;
this.quantity = quantity;
}
}
// ─── Inventory ──────────────────────────────────────────
class Inventory {
constructor() {
this.items = new Map(); // code -> Product
}
addItem(code, product) {
this.items.set(code, product);
}
getItem(code) {
return this.items.get(code) || null;
}
isAvailable(code) {
const product = this.getItem(code);
return product !== null && product.quantity > 0;
}
reduceStock(code) {
const product = this.getItem(code);
if (product && product.quantity > 0) {
product.quantity--;
}
}
getAll() {
const result = [];
for (const [code, product] of this.items) {
result.push({ code, name: product.name, price: product.price, qty: product.quantity });
}
return result;
}
}
State Classes (State Pattern)
// ─── State Interface (base class) ──────────────────────
class State {
insertCoin(machine, coin) {
throw new Error('Action not allowed in current state');
}
selectProduct(machine, code) {
throw new Error('Action not allowed in current state');
}
dispense(machine) {
throw new Error('Action not allowed in current state');
}
cancel(machine) {
throw new Error('Action not allowed in current state');
}
}
// ─── Idle State ─────────────────────────────────────────
class IdleState extends State {
insertCoin(machine, coin) {
machine.addToBalance(coin);
console.log(`[IDLE -> COIN_INSERTED] Inserted ${coin}. Balance: ${machine.getBalance()}`);
machine.setState(new CoinInsertedState());
}
selectProduct(machine, code) {
throw new Error('Please insert a coin first.');
}
dispense(machine) {
throw new Error('Please insert a coin and select a product first.');
}
cancel(machine) {
console.log('[IDLE] Nothing to cancel.');
return 0;
}
}
// ─── Coin Inserted State ────────────────────────────────
class CoinInsertedState extends State {
insertCoin(machine, coin) {
machine.addToBalance(coin);
console.log(`[COIN_INSERTED] Added ${coin}. Balance: ${machine.getBalance()}`);
// Stay in same state
}
selectProduct(machine, code) {
if (!machine.inventory.isAvailable(code)) {
throw new Error(`Product ${code} is not available.`);
}
const product = machine.inventory.getItem(code);
machine.selectedProduct = product;
machine.selectedCode = code;
console.log(`[COIN_INSERTED -> PRODUCT_SELECTED] Selected: ${product.name} ($${product.price})`);
machine.setState(new ProductSelectedState());
}
cancel(machine) {
const refund = machine.getBalance();
machine.resetBalance();
machine.selectedProduct = null;
machine.selectedCode = null;
machine.setState(new IdleState());
console.log(`[COIN_INSERTED -> IDLE] Cancelled. Refund: $${refund}`);
return refund;
}
dispense(machine) {
throw new Error('Please select a product first.');
}
}
// ─── Product Selected State ─────────────────────────────
class ProductSelectedState extends State {
insertCoin(machine, coin) {
machine.addToBalance(coin);
console.log(`[PRODUCT_SELECTED] Added ${coin}. Balance: ${machine.getBalance()}`);
// Stay — user might need to insert more
}
selectProduct(machine, code) {
throw new Error('Product already selected. Dispense or cancel.');
}
dispense(machine) {
const product = machine.selectedProduct;
const balance = machine.getBalance();
if (balance < product.price) {
const shortfall = product.price - balance;
throw new Error(`Insufficient balance. Insert $${shortfall} more.`);
}
// Transition to DISPENSING
machine.setState(new DispensingState());
console.log(`[PRODUCT_SELECTED -> DISPENSING] Dispensing ${product.name}...`);
// Execute dispensing
return machine.currentState.execute(machine);
}
cancel(machine) {
const refund = machine.getBalance();
machine.resetBalance();
machine.selectedProduct = null;
machine.selectedCode = null;
machine.setState(new IdleState());
console.log(`[PRODUCT_SELECTED -> IDLE] Cancelled. Refund: $${refund}`);
return refund;
}
}
// ─── Dispensing State ───────────────────────────────────
class DispensingState extends State {
execute(machine) {
const product = machine.selectedProduct;
const code = machine.selectedCode;
const change = machine.getBalance() - product.price;
// Reduce inventory
machine.inventory.reduceStock(code);
// Reset machine
machine.resetBalance();
machine.selectedProduct = null;
machine.selectedCode = null;
// Back to idle
machine.setState(new IdleState());
console.log(`[DISPENSING -> IDLE] Dispensed: ${product.name}. Change: $${change}`);
return {
product: product.name,
price: product.price,
change,
};
}
insertCoin(machine, coin) {
throw new Error('Machine is dispensing. Please wait.');
}
selectProduct(machine, code) {
throw new Error('Machine is dispensing. Please wait.');
}
cancel(machine) {
throw new Error('Cannot cancel during dispensing.');
}
dispense(machine) {
throw new Error('Already dispensing.');
}
}
VendingMachine (Main Controller)
// ─── VendingMachine ─────────────────────────────────────
class VendingMachine {
constructor() {
this.inventory = new Inventory();
this.currentBalance = 0;
this.currentState = new IdleState();
this.selectedProduct = null;
this.selectedCode = null;
}
// ── State management ──────────────────────────────────
setState(state) {
this.currentState = state;
}
getStateName() {
return this.currentState.constructor.name;
}
// ── Balance management ────────────────────────────────
getBalance() {
return this.currentBalance;
}
addToBalance(amount) {
this.currentBalance += amount;
}
resetBalance() {
this.currentBalance = 0;
}
// ── Public actions (delegated to current state) ───────
insertCoin(coin) {
this.currentState.insertCoin(this, coin);
}
selectProduct(code) {
this.currentState.selectProduct(this, code);
}
dispense() {
return this.currentState.dispense(this);
}
cancel() {
return this.currentState.cancel(this);
}
// ── Inventory management ──────────────────────────────
addProduct(code, name, price, quantity) {
this.inventory.addItem(code, new Product(name, price, quantity));
}
showProducts() {
return this.inventory.getAll();
}
}
Usage Walkthrough
// ─── Demo ───────────────────────────────────────────────
const machine = new VendingMachine();
// Stock the machine
machine.addProduct('A1', 'Cola', 25, 5);
machine.addProduct('A2', 'Pepsi', 30, 3);
machine.addProduct('B1', 'Chips', 40, 2);
machine.addProduct('B2', 'Candy', 15, 10);
console.log('Products:', machine.showProducts());
// [
// { code: 'A1', name: 'Cola', price: 25, qty: 5 },
// { code: 'A2', name: 'Pepsi', price: 30, qty: 3 },
// { code: 'B1', name: 'Chips', price: 40, qty: 2 },
// { code: 'B2', name: 'Candy', price: 15, qty: 10 },
// ]
// ── Happy path: buy a Cola for 25 ──────────────────────
machine.insertCoin(Coin.QUARTER); // Balance: 25
machine.selectProduct('A1'); // Selected: Cola ($25)
const result = machine.dispense(); // Dispensed: Cola. Change: $0
console.log(result);
// { product: 'Cola', price: 25, change: 0 }
// ── User inserts too much, gets change ──────────────────
machine.insertCoin(Coin.QUARTER); // Balance: 25
machine.insertCoin(Coin.DIME); // Balance: 35
machine.selectProduct('B2'); // Selected: Candy ($15)
const result2 = machine.dispense(); // Dispensed: Candy. Change: $20
console.log(result2);
// { product: 'Candy', price: 15, change: 20 }
// ── User cancels after inserting coins ──────────────────
machine.insertCoin(Coin.QUARTER); // Balance: 25
machine.insertCoin(Coin.QUARTER); // Balance: 50
const refund = machine.cancel(); // Refund: $50
console.log('Refund:', refund); // 50
// ── Insufficient balance ────────────────────────────────
machine.insertCoin(Coin.DIME); // Balance: 10
machine.selectProduct('B1'); // Selected: Chips ($40)
try {
machine.dispense(); // Error: Insufficient balance
} catch (err) {
console.log(err.message); // "Insufficient balance. Insert $30 more."
}
// Insert more and retry
machine.insertCoin(Coin.QUARTER); // Balance: 35
machine.insertCoin(Coin.NICKEL); // Balance: 40
const result3 = machine.dispense(); // Dispensed: Chips. Change: $0
console.log(result3);
Edge Cases
| Edge Case | How It Is Handled |
|---|---|
| Select product without inserting coins | IdleState.selectProduct() throws error |
| Select out-of-stock product | CoinInsertedState.selectProduct() checks isAvailable() |
| Insert coin during dispensing | DispensingState.insertCoin() throws error |
| Cancel during idle | IdleState.cancel() returns 0 gracefully |
| Cancel after selecting product | ProductSelectedState.cancel() refunds full balance |
| Insufficient balance and dispense | Throws error with exact shortfall amount |
| Multiple cancels | Each cancel resets to IDLE; subsequent cancels return 0 |
Key Takeaways
- State Pattern is the natural fit for vending machines — each state encapsulates what actions are allowed and what happens next.
- Every public method on
VendingMachinedelegates to the current state — the machine itself has no conditionals about its state. - Adding a new state (e.g.,
MaintenanceStatewhere all actions throw "Out of Service") requires zero changes to existing states. - The state transition diagram is the single most important artifact in this design — draw it first in an interview.
- Refund logic is centralized:
cancel()always refundscurrentBalanceand resets toIdleState.
Next -> 9.6.c — BookMyShow