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

  1. Requirements
  2. State Machine
  3. Entities and Enums
  4. Class Diagram
  5. Sequence Flow
  6. Complete Implementation
  7. Usage Walkthrough
  8. Edge Cases
  9. Key Takeaways

Requirements

Functional Requirements

  1. Machine holds an inventory of products, each with a name, price, and quantity.
  2. User inserts coins (1, 5, 10, 25 units).
  3. User selects a product by code (e.g., "A1", "B2").
  4. Machine dispenses the product if enough money is inserted and the item is in stock.
  5. Machine returns change (difference between inserted amount and price).
  6. 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 StateActionNext StateSide Effect
IDLEinsertCoin(coin)COIN_INSERTEDAdd coin to balance
COIN_INSERTEDinsertCoin(coin)COIN_INSERTEDAdd coin to balance
COIN_INSERTEDselectProduct(code)PRODUCT_SELECTEDValidate product exists and is in stock
COIN_INSERTEDcancel()IDLERefund all inserted coins
PRODUCT_SELECTEDdispense()DISPENSINGCheck balance >= price
PRODUCT_SELECTEDcancel()IDLERefund all inserted coins
DISPENSING(auto)IDLEDecrement 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 CaseHow It Is Handled
Select product without inserting coinsIdleState.selectProduct() throws error
Select out-of-stock productCoinInsertedState.selectProduct() checks isAvailable()
Insert coin during dispensingDispensingState.insertCoin() throws error
Cancel during idleIdleState.cancel() returns 0 gracefully
Cancel after selecting productProductSelectedState.cancel() refunds full balance
Insufficient balance and dispenseThrows error with exact shortfall amount
Multiple cancelsEach cancel resets to IDLE; subsequent cancels return 0

Key Takeaways

  1. State Pattern is the natural fit for vending machines — each state encapsulates what actions are allowed and what happens next.
  2. Every public method on VendingMachine delegates to the current state — the machine itself has no conditionals about its state.
  3. Adding a new state (e.g., MaintenanceState where all actions throw "Out of Service") requires zero changes to existing states.
  4. The state transition diagram is the single most important artifact in this design — draw it first in an interview.
  5. Refund logic is centralized: cancel() always refunds currentBalance and resets to IdleState.

Next -> 9.6.c — BookMyShow