System Design Study Notes: Vending Machine Low-Level Design (LLD)
A Vending Machine System is a classic Low-Level Design (LLD) interview topic that tests an engineer's ability to model complex operational life cycles, manage state transitions cleanly, and maintain inventory. At its core, the problem revolves around translating hardware/user interactions into a robust state machine while ensuring the code remains extensible for multi-modal payments and inventory tracking.
1. Problem Context & Functional Requirements
A vending machine is a self-service hardware apparatus that stores items, accepts payments, processes selections, and dispenses products to customers. The core engineering challenge lies in modeling the strict sequence of operational rules and ensuring that invalid operations (e.g., dispensing without payment or selecting an out-of-stock item) are strictly blocked by the system's state machine.
Functional Requirements
- Inventory Management: Maintain an accurate count of products categorized by shelves, slots, and product types (e.g., beverages, snacks).
- State Lifecycle Handling: Gracefully handle operational states: Idle, Receiving Money, Item Selection, Product Dispensing, and Out-of-Stock handling.
- Payment Processing: Support cash/coin inputs in the legacy model, with architectural support for modern payment modes (Card, UPI QR Code).
- Transaction Integrity: Ensure products are dispensed only after full payment validation. If a transaction fails or is canceled, funds must be refunded immediately.
- Change Calculation (Extensible): Calculate and return balance change when inserted funds exceed the item price.
2. Walkthrough & Mental Model: Enacting the Process
To arrive at a bulletproof low-level design, model the lifecycle by enacting the workflow from two distinct perspectives: the Machine Owner (Admin) and the Customer.
The Owner Workflow (Setup Phase)
- The machine owner opens the physical casing and inspects individual item shelves.
- The owner populates each shelf with specific item categories (e.g., Coke in Shelf A1, Chips in Shelf B2) and sets the price.
- The owner initializes machine funds (coins/bills for giving change) and sets the machine to
IdleState.
The Customer Workflow (Usage Phase)
- Browsing: Customer reviews the physical/digital display to check available items and their prices.
- Money Insertion: Customer inserts coins/bills into the machine slot. The machine transitions from
IdleStatetoHasMoneyState. - Selection: Customer inputs the code/button corresponding to an item shelf. The machine transitions to
SelectionState. - Validation & Dispensing:
- Case A (Success): Inserted funds Item Price AND Item Stock . Machine transitions to
DispenseState, drops the item into the tray, refunds change, and returns toIdleState. - Case B (Insufficient Funds): Prompt user to insert more money or press refund.
- Case C (Out of Stock): Transitions to
OutOfStockState, returns inserted money, and resets toIdleState.
- Case A (Success): Inserted funds Item Price AND Item Stock . Machine transitions to
- Cancellation: Customer can hit "Cancel/Refund" at any point prior to dispensing, triggering a full refund.
Real-Life Analogy
Vending Machine vs. Warehouse Inventory System
A vending machine is essentially a miniaturized, automated warehouse. While an enterprise Warehouse Management System (WMS) handles bulk shipments, cross-docking, and forklift routing over thousands of square feet, a vending machine operates as a localized WMS bounded by physical shelves, strict single-item picking mechanics, and an embedded payment terminal.
3. High-Level Architecture & Domain Entities
To construct a decoupled object-oriented architecture, break down the system into core domain entities, value objects, and contextual state managers.
Domain Entity Summary
ItemType: Enum defining the categorical classification of items for filtering and reporting.Item: Domain model capturing specific product details (ID, name, price, type).ItemShelf: Models a single physical slot/rack inside the machine containing identical items.Inventory: Aggregates allItemShelfinstances; exposes methods to query stock and update quantities.Coin: Enum representing standard legal tender denominations recognized by the machine's coin validator.
4. Design Pattern Breakdown: Choosing the Right Tools
Designing a vending machine in an interview setting requires balancing pattern richness against code completion constraints.
Interview Survival Tip: Focus live implementation effort entirely on the State Pattern. Do not try to fully code every auxiliary pattern (Strategy, Observer, Factory) on the whiteboard/editor within a 45-minute interview, or you risk running out of time. Verbally explain how Strategy and Observer integrate into your core State infrastructure.
5. State Machine Lifecycle & Workflow Visualizations
The state transitions form the core logic of the system. Below is the operational flow map detailing valid transition paths and fallback mechanisms.
Complete Transaction Flow Walkthrough
6. Core Implementation: Domain Models & Inventory System
Below is the production-grade implementation of domain entities, enumerations, and the inventory holding structure.
package com.playbook.vendingmachine.model;
import java.util.ArrayList;
import java.util.List;
// Enum for Item Types
public enum ItemType {
COKE,
PEPSI,
JUICE,
SODA,
CHIPS
}
// Domain Object: Item
public class Item {
private final String id;
private final String name;
private final double price;
private final ItemType type;
public Item(String id, String name, double price, ItemType type) {
this.id = id;
this.name = name;
this.price = price;
this.type = type;
}
public String getId() { return id; }
public String getName() { return name; }
public double getPrice() { return price; }
public ItemType getType() { return type; }
}
// Enum for Legal Tender Coins
public enum Coin {
RUPEE_1(1.0),
RUPEE_2(2.0),
RUPEE_5(5.0),
RUPEE_10(10.0),
RUPEE_20(20.0);
private final double value;
Coin(double value) {
this.value = value;
}
public double getValue() { return value; }
}
// Container for physical slots in the machine
public class ItemShelf {
private final int shelfCode;
private final List<Item> items;
public ItemShelf(int shelfCode) {
this.shelfCode = shelfCode;
this.items = new ArrayList<>();
}
public int getShelfCode() { return shelfCode; }
public List<Item> getItems() { return items; }
public boolean isSoldOut() {
return items.isEmpty();
}
public void addItem(Item item) {
items.add(item);
}
public Item removeItem() {
if (!items.isEmpty()) {
return items.remove(0);
}
return null;
}
}
// Aggregate Inventory Entity
public class Inventory {
private final List<ItemShelf> inventory;
public Inventory(int shelfCount) {
this.inventory = new ArrayList<>();
for (int i = 1; i <= shelfCount; i++) {
inventory.add(new ItemShelf(i));
}
}
public List<ItemShelf> getInventory() {
return inventory;
}
public ItemShelf getShelf(int shelfCode) {
for (ItemShelf shelf : inventory) {
if (shelf.getShelfCode() == shelfCode) {
return shelf;
}
}
throw new IllegalArgumentException("Invalid Shelf Code: " + shelfCode);
}
public void addItem(Item item, int shelfCode) {
ItemShelf shelf = getShelf(shelfCode);
shelf.addItem(item);
}
}
7. Deep Dive: Implementing the State Design Pattern
The State Pattern forces each state object to implement a standard interface. Any invalid action performed in an incorrect state throws an exception or logs a descriptive defensive message.
package com.playbook.vendingmachine.state;
import com.playbook.vendingmachine.context.VendingMachineContext;
import com.playbook.vendingmachine.model.Coin;
import com.playbook.vendingmachine.model.Item;
public interface VendingMachineState {
void insertCoin(VendingMachineContext context, Coin coin);
void selectProduct(VendingMachineContext context, int shelfCode);
void dispense(VendingMachineContext context, int shelfCode);
void refund(VendingMachineContext context);
void next(VendingMachineContext context);
}
Concrete State Implementations
Idle State
package com.playbook.vendingmachine.state.impl;
import com.playbook.vendingmachine.context.VendingMachineContext;
import com.playbook.vendingmachine.model.Coin;
import com.playbook.vendingmachine.state.VendingMachineState;
public class IdleState implements VendingMachineState {
@Override
public void insertCoin(VendingMachineContext context, Coin coin) {
System.out.println("Coin inserted: " + coin.getValue());
context.addCoin(coin);
context.setState(new HasMoneyState());
}
@Override
public void selectProduct(VendingMachineContext context, int shelfCode) {
System.out.println("Error: Please insert money before selecting a product.");
}
@Override
public void dispense(VendingMachineContext context, int shelfCode) {
System.out.println("Error: Cannot dispense. No money inserted and no item selected.");
}
@Override
public void refund(VendingMachineContext context) {
System.out.println("No money to refund.");
}
@Override
public void next(VendingMachineContext context) {
if (!context.getCoinList().isEmpty()) {
context.setState(new HasMoneyState());
} else {
System.out.println("Vending machine remains in IDLE state.");
}
}
}
Has Money State
package com.playbook.vendingmachine.state.impl;
import com.playbook.vendingmachine.context.VendingMachineContext;
import com.playbook.vendingmachine.model.Coin;
import com.playbook.vendingmachine.model.ItemShelf;
import com.playbook.vendingmachine.state.VendingMachineState;
public class HasMoneyState implements VendingMachineState {
@Override
public void insertCoin(VendingMachineContext context, Coin coin) {
System.out.println("Additional coin inserted: " + coin.getValue());
context.addCoin(coin);
}
@Override
public void selectProduct(VendingMachineContext context, int shelfCode) {
ItemShelf shelf = context.getInventory().getShelf(shelfCode);
if (shelf.isSoldOut()) {
System.out.println("Item in shelf " + shelfCode + " is Out of Stock!");
context.setState(new OutOfStockState());
return;
}
double itemPrice = shelf.getItems().get(0).getPrice();
double currentBalance = context.getInsertedBalance();
if (currentBalance < itemPrice) {
System.out.println("Insufficient balance! Item price: $" + itemPrice + ", Inserted: $" + currentBalance);
return;
}
System.out.println("Product selected successfully from shelf " + shelfCode);
context.setState(new SelectionState(shelfCode));
}
@Override
public void dispense(VendingMachineContext context, int shelfCode) {
System.out.println("Error: Select product first.");
}
@Override
public void refund(VendingMachineContext context) {
System.out.println("Refunding total inserted amount: $" + context.getInsertedBalance());
context.resetBalanceAndCoins();
context.setState(new IdleState());
}
@Override
public void next(VendingMachineContext context) {
context.setState(new SelectionState(-1));
}
}
Selection State & Dispense State
package com.playbook.vendingmachine.state.impl;
import com.playbook.vendingmachine.context.VendingMachineContext;
import com.playbook.vendingmachine.model.Coin;
import com.playbook.vendingmachine.model.Item;
import com.playbook.vendingmachine.model.ItemShelf;
import com.playbook.vendingmachine.state.VendingMachineState;
public class SelectionState implements VendingMachineState {
private final int selectedShelfCode;
public SelectionState(int shelfCode) {
this.selectedShelfCode = shelfCode;
}
@Override
public void insertCoin(VendingMachineContext context, Coin coin) {
System.out.println("Cannot insert coin during processing selection.");
}
@Override
public void selectProduct(VendingMachineContext context, int shelfCode) {
System.out.println("Selection already in progress for shelf: " + selectedShelfCode);
}
@Override
public void dispense(VendingMachineContext context, int shelfCode) {
System.out.println("Transitioning to DispenseState...");
context.setState(new DispenseState(selectedShelfCode));
context.getCurrentState().dispense(context, selectedShelfCode);
}
@Override
public void refund(VendingMachineContext context) {
System.out.println("Canceling transaction. Refunding: $" + context.getInsertedBalance());
context.resetBalanceAndCoins();
context.setState(new IdleState());
}
@Override
public void next(VendingMachineContext context) {
context.setState(new DispenseState(selectedShelfCode));
}
}
public class DispenseState implements VendingMachineState {
private final int shelfCode;
public DispenseState(int shelfCode) {
this.shelfCode = shelfCode;
}
@Override
public void insertCoin(VendingMachineContext context, Coin coin) {
System.out.println("Error: Currently dispensing product. Cannot insert coin.");
}
@Override
public void selectProduct(VendingMachineContext context, int shelfCode) {
System.out.println("Error: Currently dispensing product.");
}
@Override
public void dispense(VendingMachineContext context, int shelfCode) {
ItemShelf shelf = context.getInventory().getShelf(shelfCode);
Item item = shelf.removeItem();
double change = context.getInsertedBalance() - item.getPrice();
System.out.println("==========================================");
System.out.println("DISPENSED ITEM: " + item.getName() + " ($" + item.getPrice() + ")");
if (change > 0) {
System.out.println("CHANGE RETURNED: $" + change);
}
System.out.println("==========================================");
context.resetBalanceAndCoins();
context.setState(new IdleState());
}
@Override
public void refund(VendingMachineContext context) {
System.out.println("Error: Item already dispensed. Refund unavailable.");
}
@Override
public void next(VendingMachineContext context) {
context.setState(new IdleState());
}
}
Out Of Stock State
package com.playbook.vendingmachine.state.impl;
import com.playbook.vendingmachine.context.VendingMachineContext;
import com.playbook.vendingmachine.model.Coin;
import com.playbook.vendingmachine.state.VendingMachineState;
public class OutOfStockState implements VendingMachineState {
@Override
public void insertCoin(VendingMachineContext context, Coin coin) {
System.out.println("Item out of stock. Refunding inserted coin...");
}
@Override
public void selectProduct(VendingMachineContext context, int shelfCode) {
System.out.println("Item out of stock. Please select another item or ask for a refund.");
}
@Override
public void dispense(VendingMachineContext context, int shelfCode) {
System.out.println("Cannot dispense out of stock item.");
}
@Override
public void refund(VendingMachineContext context) {
System.out.println("Refunding balance: $" + context.getInsertedBalance());
context.resetBalanceAndCoins();
context.setState(new IdleState());
}
@Override
public void next(VendingMachineContext context) {
context.setState(new IdleState());
}
}
8. State Context Handler & Defensive Workflow Execution
The VendingMachineContext manages operational state, holds inventory references, tracks active coin balances, and exposes high-level delegation triggers.
package com.playbook.vendingmachine.context;
import com.playbook.vendingmachine.model.Coin;
import com.playbook.vendingmachine.model.Inventory;
import com.playbook.vendingmachine.state.VendingMachineState;
import com.playbook.vendingmachine.state.impl.IdleState;
import java.util.ArrayList;
import java.util.List;
public class VendingMachineContext {
private VendingMachineState currentState;
private final Inventory inventory;
private final List<Coin> coinList;
public VendingMachineContext(int totalShelves) {
this.currentState = new IdleState();
this.inventory = new Inventory(totalShelves);
this.coinList = new ArrayList<>();
}
public VendingMachineState getCurrentState() {
return currentState;
}
public void setState(VendingMachineState state) {
System.out.println("[STATE TRANSITION] Moving to: " + state.getClass().getSimpleName());
this.currentState = state;
}
public Inventory getInventory() {
return inventory;
}
public List<Coin> getCoinList() {
return coinList;
}
public void addCoin(Coin coin) {
this.coinList.add(coin);
}
public double getInsertedBalance() {
return coinList.stream().mapToDouble(Coin::getValue).sum();
}
public void resetBalanceAndCoins() {
this.coinList.clear();
}
// Defensive Delegation Triggers
public void insertCoin(Coin coin) {
currentState.insertCoin(this, coin);
}
public void selectProduct(int shelfCode) {
currentState.selectProduct(this, shelfCode);
}
public void dispense(int shelfCode) {
currentState.dispense(this, shelfCode);
}
public void refund() {
currentState.refund(this);
}
}
End-to-End Application Execution Flow
package com.playbook.vendingmachine;
import com.playbook.vendingmachine.context.VendingMachineContext;
import com.playbook.vendingmachine.model.Coin;
import com.playbook.vendingmachine.model.Item;
import com.playbook.vendingmachine.model.ItemType;
public class Main {
public static void main(String[] args) {
System.out.println("=== INITIALIZING VENDING MACHINE SYSTEM ===");
VendingMachineContext machine = new VendingMachineContext(5);
// Populate Inventory
Item coke1 = new Item("C1", "Coca Cola", 15.0, ItemType.COKE);
Item coke2 = new Item("C2", "Coca Cola", 15.0, ItemType.COKE);
Item chips = new Item("P1", "Lays Chips", 10.0, ItemType.CHIPS);
machine.getInventory().addItem(coke1, 1);
machine.getInventory().addItem(coke2, 1);
machine.getInventory().addItem(chips, 2);
System.out.println("\n--- TRANSACTION 1: Successful Purchase ---");
machine.insertCoin(Coin.RUPEE_10);
machine.insertCoin(Coin.RUPEE_10); // Balance = $20
machine.selectProduct(1); // Coke Price = $15
machine.dispense(1); // Dispenses Coke, Returns $5 change
System.out.println("\n--- TRANSACTION 2: Insufficient Money ---");
machine.insertCoin(Coin.RUPEE_5); // Balance = $5
machine.selectProduct(2); // Chips Price = $10 (Fails)
machine.refund(); // Refund $5, back to IdleState
System.out.println("\n--- TRANSACTION 3: Out of Stock Failure ---");
machine.insertCoin(Coin.RUPEE_20);
machine.selectProduct(5); // Empty Shelf (Fails -> OutOfStockState)
machine.refund(); // Returns $20, back to IdleState
}
}
9. Extensibility: Integrating Strategy Pattern for Modern Payments
While legacy systems rely exclusively on coin validators, modern smart vending machines accept Card scans and UPI QR codes.
Strategy Implementation
package com.playbook.vendingmachine.payment;
public interface PaymentStrategy {
boolean processPayment(double amount);
}
public class CoinPaymentStrategy implements PaymentStrategy {
private final double insertedAmount;
public CoinPaymentStrategy(double insertedAmount) {
this.insertedAmount = insertedAmount;
}
@Override
public boolean processPayment(double amount) {
if (insertedAmount >= amount) {
System.out.println("Coin Payment processed successfully.");
return true;
}
System.out.println("Coin Payment failed: Insufficient funds.");
return false;
}
}
public class CardPaymentStrategy implements PaymentStrategy {
private final String cardNumber;
public CardPaymentStrategy(String cardNumber) {
this.cardNumber = cardNumber;
}
@Override
public boolean processPayment(double amount) {
System.out.println("Contacting payment gateway for card " + cardNumber + "...");
System.out.println("Debited $" + amount + " via Card.");
return true;
}
}
Refactored Context with Payment Strategy Injection
public class VendingMachineContextExtensible {
private PaymentStrategy paymentStrategy;
public void setPaymentStrategy(PaymentStrategy paymentStrategy) {
this.paymentStrategy = paymentStrategy;
}
public boolean executePayment(double amount) {
if (paymentStrategy == null) {
throw new IllegalStateException("Payment method strategy not selected.");
}
return paymentStrategy.processPayment(amount);
}
}
10. Industry Reality, Interview Strategy & Edge Cases
Production Architectural Refactoring
In an actual production deployment, aggregating state tracking, inventory manipulation, and payment validation inside a single VendingMachineContext class violates the Single Responsibility Principle (SRP). Standard enterprise applications decouple these concerns into three discrete components orchestrated by a top-level controller:
Edge Cases & Defensive Considerations
-
Exact Change Verification:
- Problem: Machine lacks change coins/bills to return balance after purchase.
- Fix: Introduce a
GiveChangeBackStateor check the physical change vault prior to moving toSelectionState. If change cannot be provided, block transaction before accepting non-exact payment.
-
Concurrent Hardware Inputs:
- Problem: Two users attempt hardware interactions simultaneously (e.g., rapid button press while inserting bills).
- Fix: Synchronize critical state transitions using Java
ReentrantLockorsynchronizedmodifiers on state manipulation APIs withinVendingMachineContext.
-
Item Drop/Dispense Hardware Failure:
- Problem: Motor spins to dispense item, but sensor fails to detect product dropping into the tray.
- Fix: Set a physical timeout. If the drop sensor isn't triggered within , abort transaction, mark the shelf as out-of-order, log a maintenance event, and trigger a complete refund.
11. Summary and Key Takeaways
- State Machine Core: The State Pattern is the foundational architectural choice for a Vending Machine LLD, eliminating nested conditionals and making transitions explicit.
- Defensive Coding: Each concrete state class must explicitly reject invalid actions (e.g., dispensing during
IdleStateor accepting coins duringDispenseState). - Decoupled Architecture: Domain models (
Item,ItemShelf,Inventory) must remain separate from state contexts and payment processing engines. - Interview Execution Focus: Prioritize implementing standard lifecycle states (
Idle,HasMoney,Selection,Dispense,OutOfStock) over secondary features like payment strategy or stock monitoring. - Strategy Extensibility: Injecting a
PaymentStrategyinterface allows support for coins, cash, credit cards, and UPI payments without altering state transition logic. - Separation of Concerns: In enterprise settings, decouple context management from
InventoryManagerandPaymentProcessorvia a unifyingVendingMachineController. - Change Logic Optimization: Always validate change capability prior to authorizing transactions to prevent stranded balance conditions.