Back to LLD Concepts
LLD Tutorial
Medium

Vending Machine System Design

Design a fully functional vending machine that manages slot-based inventory, supports multiple payment methods, enforces a strict state machine, and handles concurrent purchases safely — a classic LLD interview question. Complete with UML, state diagrams, and annotated code in Java & Python.

OOPStrategy PatternState MachineSingletonThread SafetySOLID Principles

Before coding in an LLD interview, spend 3–5 minutes clarifying requirements. These are the core functional requirements for a vending machine:

  • Support multiple product types (snacks, beverages, candy, meals) with different prices
  • Maintain slot-based inventory — each slot holds one product type with a stock count
  • Allow customers to browse available products and select by slot code
  • Support multiple payment methods: cash, card, digital wallet (pluggable)
  • Return correct change when customer over-pays with cash
  • Reject payment if insufficient amount is inserted (cash) or card is declined
  • Allow cancellation at any point before dispensing — refund any inserted cash
  • Dispense product and update stock atomically — no double-dispense on concurrent requests
  • Singleton machine instance per physical unit — single source of truth for state
  • Admin operations: restock slots, set machine out-of-service
  • Maintain a transaction log for auditing
💡 Interview tip: Ask whether payment is in-scope or delegated to a gateway, whether the machine is connected to a backend (IoT/telemetry), and whether you need to model the physical coin/note mechanism for cash — these dramatically change the design.

Identify the real-world objects in the system. The key insight here is the separation between Product (what's being sold) and Slot (where it lives physically in the machine).

Class / InterfaceTypeResponsibility
ProductCategoryEnumBEVERAGE · SNACK · CANDY · MEAL
MachineStateEnumIDLE · SELECTED · PENDING · DISPENSING · OOS
PaymentMethodEnumCASH · CARD · DIGITAL_WALLET
ProductClassName, price, category, stock count
SlotClassPhysical slot holding a product
InventoryClassSlot map + dual-index lookup
PaymentProcessorInterfaceprocessPayment / processRefund
TransactionClassAudit record: product, amount, status
VendingMachineSingletonOrchestrates state machine + inventory
VendingMachine (Singleton)
├── state: MachineState   ← IDLE / SELECTED / PENDING / DISPENSING / OOS
├── inventory: Inventory
│   └── slots: Map<slotCode, Slot>
│       └── product: Product  (name, price, qty)
├── transactionLog: List<Transaction>
│   ├── product: Product
│   ├── amountPaid: double
│   └── status: SUCCESS / FAILED / REFUNDED
└── activeProcessor: PaymentProcessor  (session-scoped)
    ├── CashProcessor    (tracks insertedAmount)
    ├── CardProcessor    (calls gateway)
    └── DigitalWalletProcessor (debit/credit)

The vending machine has a strict linear flow: select → pay → dispense. Any deviation (cancel, payment failure, empty slot) must be handled gracefully.

Vending Machine — Purchase FlowCustomer ArrivesBrowse InventorygetAvailableProducts()Select SlotselectProduct("A2")Choose PaymentCash / Card / WalletPaymentApproved?NoPayment FailedRetry or CancelYesDispense Productstock-- / log transactionReturn Changeif amountPaid > priceTransaction Complete ✓Cancel / Error FlowCustomer Wants to CancelPaymentInserted?YesRefund Inserted AmountprocessRefund(inserted)No cashReset to IDLEOut of ServiceAdmin sets OOS flagAll user actions rejecteduntil restored by admin

Happy Path

Select slot → choose payment → tender amount → product dispensed + change returned → back to IDLE.

Payment Failure

If processPayment() returns false (declined card, insufficient cash), the machine stays in PAYMENT_PENDING. Customer can retry or cancel.

Cancel Flow

Customer can cancel at PRODUCT_SELECTED or PAYMENT_PENDING. Any inserted cash is immediately refunded before resetting to IDLE.

Out of Service

Admin sets OOS flag. All user-facing actions throw immediately. Only admin operations (restock, restore) are permitted.

Drawing this on a whiteboard before coding shows structured thinking. Key relationships to highlight: VendingMachine owns Inventory (composition), Inventory owns Slots (composition), and PaymentProcessor is an interface (Strategy pattern, not composition).

UML Class Diagram — Vending Machine System«singleton»VendingMachine- machineId: String- state: MachineState- inventory: Inventory- transactionLog: List- activeProcessor: PaymentProcessor+ selectProduct(slotCode)+ choosePayment(processor)+ completePayment(amount): Txn+ cancel()+ restock(slot, units)Inventory- slots: Map<String,Slot>- productIndex: Map+ findBySlotCode()+ findByProductId()+ availableProducts()+ restock(slot, units)Slot- slotCode: String- product: Product?+ isOccupied(): bool+ assign(product)Product- productId: String- name: String- price: double- category: ProductCategory- quantity: int+ isAvailable(): bool+ decrementStock()+ restock(units)Transaction- transactionId: UUID- product: Product- amountPaid: double- paymentMethod: PaymentMethod- status: TransactionStatus+ complete(change)+ fail()+ refund()«interface»PaymentProcessor+ processPayment(amt, ref): bool+ processRefund(amt, ref): double+ getMethod(): PaymentMethodCashProcessor- insertedAmount+ insertCash(amt)+ process...()CardProcessor+ process...()DigitalWallet\nProcessor- walletId+ process...()10..*uses1..*1ImplementationAssociationSingletonInterfaceConcrete Class

Solid lines = association/composition  |  Dashed lines = dependency  |  Hollow triangles = inheritance/implementation

Why a State Machine?

A vending machine is a classic finite state machine (FSM). Without explicit state tracking, a customer could theoretically call completePayment() without calling choosePayment() first, leading to null pointer exceptions or undefined behaviour. Every action validates the current state before proceeding.
State Machine — MachineState TransitionsIDLEWaiting for customerPRODUCT_SELECTEDItem chosenPAYMENT_PENDINGAwaiting paymentDISPENSINGReleasing productOUT_OF_SERVICEAdmin lockedselectProduct()choosePayment()paid ✓resetSession()cancel()cancel() + refundfailedadmin sets OOSadmin restores
Valid transitions:
  IDLE             → PRODUCT_SELECTED   (selectProduct)
  PRODUCT_SELECTED → PAYMENT_PENDING    (choosePayment)
  PRODUCT_SELECTED → IDLE               (cancel)
  PAYMENT_PENDING  → DISPENSING         (completePayment — paid OK)
  PAYMENT_PENDING  → PAYMENT_PENDING    (completePayment — payment failed, retry)
  PAYMENT_PENDING  → IDLE               (cancel + refund)
  DISPENSING       → IDLE               (resetSession after dispense)
  IDLE             → OUT_OF_SERVICE     (admin)
  OUT_OF_SERVICE   → IDLE               (admin)

Invalid (throw IllegalStateException):
  IDLE             → PAYMENT_PENDING    (skipped selectProduct)
  PAYMENT_PENDING  → DISPENSING         (skipped choosePayment)
  OUT_OF_SERVICE   → PRODUCT_SELECTED   (machine offline)

Enums make state transitions and comparisons type-safe. MachineState is the most critical — every user-facing method starts by checking the current state against the expected state.

// ── Enums ─────────────────────────────────────────────────────
public enum ProductCategory { BEVERAGE, SNACK, CANDY, MEAL }

public enum PaymentMethod { CASH, CARD, DIGITAL_WALLET }

public enum MachineState { IDLE, PRODUCT_SELECTED, PAYMENT_PENDING, DISPENSING, OUT_OF_SERVICE }

public enum TransactionStatus { SUCCESS, FAILED, REFUNDED, PENDING }

Single Responsibility: Product vs. Slot

Product models the item being sold (name, price, stock). Slot models the physical location in the machine. Separating them allows the same product to appear in multiple slots, and slots can be empty (no product assigned).

A real vending machine uses slot codes like "A1", "B3"— the row letter and column number printed on the machine's keypad. The customer types this code to select their item.

// ── Product ────────────────────────────────────────────────────
public class Product {
    private final String productId;    // e.g. "P001"
    private final String name;
    private final double price;
    private final ProductCategory category;
    private int quantity;

    public Product(String productId, String name, double price,
                   ProductCategory category, int quantity) {
        this.productId = productId;
        this.name      = name;
        this.price     = price;
        this.category  = category;
        this.quantity  = quantity;
    }

    public boolean isAvailable()   { return quantity > 0; }
    public void decrementStock()   {
        if (quantity <= 0) throw new IllegalStateException("Out of stock: " + name);
        this.quantity--;
    }
    public void restock(int units) { this.quantity += units; }

    public String          getProductId() { return productId; }
    public String          getName()      { return name; }
    public double          getPrice()     { return price; }
    public ProductCategory getCategory()  { return category; }
    public int             getQuantity()  { return quantity; }
}
// ── Slot ───────────────────────────────────────────────────────
// A physical slot in the machine (e.g. row A, column 3 → "A3")
public class Slot {
    private final String slotCode;    // e.g. "A3"
    private Product product;

    public Slot(String slotCode, Product product) {
        this.slotCode = slotCode;
        this.product  = product;
    }

    public boolean isOccupied()       { return product != null && product.isAvailable(); }
    public void    assign(Product p)  { this.product = p; }
    public Product getProduct()       { return product; }
    public String  getSlotCode()      { return slotCode; }
}

Strategy Pattern

PaymentProcessor is an interface. Adding UPI, crypto, or loyalty points requires implementing the interface — zero changes to VendingMachine. The processor is injected at runtime when the customer selects a payment method.
Strategy Pattern — Pluggable PaymentVendingMachine- activeProcessor+ completePayment(amt) → processor.processPayment()delegates«interface»PaymentProcessor+ processPayment(amt, ref)+ processRefund(amt, ref)CashProcessor- insertedAmountinsertCash(amt)dispenses physical changeCardProcessorcalls payment gatewayapprove / declinerefund via reversalDigitalWallet- walletIddebit / credit walletUPI, PayTM, GPay…

Cash is the most complex processor — it must track how much the customer has inserted (across potentially multiple insertions) and dispense physical change on exit. Card and wallet processors are simpler — they delegate to an external gateway.

// ── Payment Processor (Strategy Pattern) ──────────────────────
public interface PaymentProcessor {
    boolean processPayment(double amount, String transactionRef);
    double  processRefund(double amount, String transactionRef);
    PaymentMethod getMethod();
}

public class CashProcessor implements PaymentProcessor {
    private double insertedAmount = 0.0;

    public void insertCash(double amount) { this.insertedAmount += amount; }

    @Override
    public boolean processPayment(double amount, String ref) {
        if (insertedAmount >= amount) {
            insertedAmount -= amount;
            return true;
        }
        return false;
    }

    @Override
    public double processRefund(double amount, String ref) {
        // Return change physically (dispense coins/notes)
        double change = insertedAmount;
        insertedAmount = 0.0;
        System.out.printf("Dispensing change: ₹%.2f%n", change);
        return change;
    }

    public double getInsertedAmount() { return insertedAmount; }

    @Override public PaymentMethod getMethod() { return PaymentMethod.CASH; }
}

public class CardProcessor implements PaymentProcessor {
    @Override
    public boolean processPayment(double amount, String ref) {
        // In real code: call payment gateway API
        System.out.printf("Charging card ₹%.2f (ref: %s)%n", amount, ref);
        return true; // simulate approval
    }

    @Override
    public double processRefund(double amount, String ref) {
        System.out.printf("Refunding ₹%.2f to card (ref: %s)%n", amount, ref);
        return amount;
    }

    @Override public PaymentMethod getMethod() { return PaymentMethod.CARD; }
}

public class DigitalWalletProcessor implements PaymentProcessor {
    private final String walletId;

    public DigitalWalletProcessor(String walletId) { this.walletId = walletId; }

    @Override
    public boolean processPayment(double amount, String ref) {
        System.out.printf("Debiting wallet %s ₹%.2f%n", walletId, amount);
        return true; // simulate approval
    }

    @Override
    public double processRefund(double amount, String ref) {
        System.out.printf("Crediting wallet %s ₹%.2f%n", walletId, amount);
        return amount;
    }

    @Override public PaymentMethod getMethod() { return PaymentMethod.DIGITAL_WALLET; }
}

The transaction is the audit record of every purchase attempt. It records what was bought, how much was paid, and the outcome. The state transitions are:PENDINGSUCCESS (paid + dispensed) orFAILED (payment declined) orREFUNDED (cancelled).

transactionId

UUID — primary key for audit log

amountPaid

What the customer actually tendered

changeReturned

Set on complete() — amountPaid minus price

status

PENDING → SUCCESS / FAILED / REFUNDED

// ── Transaction ────────────────────────────────────────────────
import java.time.Instant;
import java.util.UUID;

public class Transaction {
    private final String          transactionId;
    private final Product         product;
    private final double          amountPaid;
    private final PaymentMethod   paymentMethod;
    private final Instant         timestamp;
    private       TransactionStatus status;
    private       double          changeReturned;

    public Transaction(Product product, double amountPaid, PaymentMethod method) {
        this.transactionId = UUID.randomUUID().toString();
        this.product       = product;
        this.amountPaid    = amountPaid;
        this.paymentMethod = method;
        this.timestamp     = Instant.now();
        this.status        = TransactionStatus.PENDING;
    }

    public void complete(double change) {
        this.changeReturned = change;
        this.status         = TransactionStatus.SUCCESS;
    }

    public void fail()   { this.status = TransactionStatus.FAILED; }
    public void refund() { this.status = TransactionStatus.REFUNDED; }

    // Getters
    public String            getTransactionId() { return transactionId; }
    public Product           getProduct()       { return product; }
    public double            getAmountPaid()    { return amountPaid; }
    public PaymentMethod     getPaymentMethod() { return paymentMethod; }
    public TransactionStatus getStatus()        { return status; }
    public Instant           getTimestamp()     { return timestamp; }
    public double            getChangeReturned(){ return changeReturned; }
}

Dual-Index Pattern

The Inventory maintains two indexes: slotCode → Slot for customer selection, and productId → Slot for admin restocking. Both lookups are O(1). This is a classic space-time tradeoff — a small memory overhead for constant-time access in both directions.
// ── Inventory ──────────────────────────────────────────────────
import java.util.*;

public class Inventory {
    // slotCode -> Slot
    private final Map<String, Slot>    slots;
    // productId -> Slot  (reverse index for O(1) lookup)
    private final Map<String, Slot>    productIndex;

    public Inventory() {
        this.slots        = new LinkedHashMap<>();
        this.productIndex = new HashMap<>();
    }

    public void addSlot(String slotCode, Product product) {
        Slot slot = new Slot(slotCode, product);
        slots.put(slotCode, slot);
        if (product != null) productIndex.put(product.getProductId(), slot);
    }

    public Slot findBySlotCode(String slotCode) {
        return slots.get(slotCode);
    }

    public Slot findByProductId(String productId) {
        return productIndex.get(productId);
    }

    public Map<String, Slot> getAllSlots() {
        return Collections.unmodifiableMap(slots);
    }

    public List<Product> getAvailableProducts() {
        List<Product> available = new ArrayList<>();
        for (Slot slot : slots.values()) {
            if (slot.isOccupied()) available.add(slot.getProduct());
        }
        return available;
    }

    public void restock(String slotCode, int units) {
        Slot slot = slots.get(slotCode);
        if (slot == null || slot.getProduct() == null)
            throw new IllegalArgumentException("Slot not found: " + slotCode);
        slot.getProduct().restock(units);
        System.out.printf("Restocked %s in slot %s (+%d units)%n",
            slot.getProduct().getName(), slotCode, units);
    }
}

Thread Safety Warning

selectProduct(), choosePayment(), completePayment(), and cancel() all acquire a reentrant lock. Without locking, two customers could simultaneously select the last item in a slot, both pass the availability check, and both get their product — giving away stock for free.

The VendingMachine is the orchestrator. It ties together the state machine, inventory, payment processor, and transaction log. Note how resetSession() is always called at the end — whether the purchase succeeds or is cancelled — to ensure the machine always returns to a clean IDLE state.

// ── VendingMachine (State Machine + Singleton) ─────────────────
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;

public class VendingMachine {
    private static volatile VendingMachine instance;
    private final ReentrantLock lock = new ReentrantLock();

    private final String     machineId;
    private       MachineState state;
    private final Inventory  inventory;
    private final List<Transaction> transactionLog;

    // State during a purchase session
    private Product          selectedProduct;
    private PaymentProcessor activeProcessor;
    private Transaction      currentTransaction;

    private VendingMachine(String machineId) {
        this.machineId      = machineId;
        this.state          = MachineState.IDLE;
        this.inventory      = new Inventory();
        this.transactionLog = new ArrayList<>();
    }

    // ── Singleton ──────────────────────────────────────────────
    public static VendingMachine getInstance(String machineId) {
        if (instance == null) {
            synchronized (VendingMachine.class) {
                if (instance == null) instance = new VendingMachine(machineId);
            }
        }
        return instance;
    }

    // ── User-facing actions ────────────────────────────────────

    /** Step 1: Customer selects a product by slot code or product ID */
    public void selectProduct(String slotCode) {
        lock.lock();
        try {
            if (state != MachineState.IDLE)
                throw new IllegalStateException("Machine busy. Cancel current selection first.");

            Slot slot = inventory.findBySlotCode(slotCode);
            if (slot == null || !slot.isOccupied())
                throw new IllegalArgumentException("Slot " + slotCode + " is empty or invalid.");

            this.selectedProduct = slot.getProduct();
            this.state = MachineState.PRODUCT_SELECTED;
            System.out.printf("Selected: %s (₹%.2f)%n",
                selectedProduct.getName(), selectedProduct.getPrice());
        } finally {
            lock.unlock();
        }
    }

    /** Step 2: Choose payment method */
    public void choosePayment(PaymentProcessor processor) {
        lock.lock();
        try {
            if (state != MachineState.PRODUCT_SELECTED)
                throw new IllegalStateException("Select a product first.");
            this.activeProcessor = processor;
            this.state = MachineState.PAYMENT_PENDING;
            this.currentTransaction = new Transaction(
                selectedProduct, 0, processor.getMethod());
            System.out.printf("Payment method: %s%n", processor.getMethod());
        } finally {
            lock.unlock();
        }
    }

    /** Step 3: Process payment and dispense product */
    public Transaction completePayment(double amountTendered) {
        lock.lock();
        try {
            if (state != MachineState.PAYMENT_PENDING)
                throw new IllegalStateException("No payment pending.");

            double price = selectedProduct.getPrice();
            boolean paid = activeProcessor.processPayment(amountTendered,
                currentTransaction.getTransactionId());

            if (!paid) {
                currentTransaction.fail();
                System.out.println("Payment failed. Please try again or cancel.");
                // Stay in PAYMENT_PENDING so user can retry
                return currentTransaction;
            }

            // Payment succeeded → dispense
            state = MachineState.DISPENSING;
            selectedProduct.decrementStock();

            double change = amountTendered - price;
            if (change > 0) activeProcessor.processRefund(change,
                currentTransaction.getTransactionId());

            currentTransaction.complete(change);
            transactionLog.add(currentTransaction);

            System.out.printf("Dispensing: %s. Change: ₹%.2f%n",
                selectedProduct.getName(), change);

            Transaction result = currentTransaction;
            resetSession();
            return result;

        } finally {
            lock.unlock();
        }
    }

    /** Cancel at any point → refund & return to IDLE */
    public void cancel() {
        lock.lock();
        try {
            if (state == MachineState.IDLE) return;
            if (activeProcessor != null && state == MachineState.PAYMENT_PENDING) {
                activeProcessor.processRefund(0,
                    currentTransaction != null ? currentTransaction.getTransactionId() : "");
                if (currentTransaction != null) currentTransaction.refund();
            }
            System.out.println("Transaction cancelled.");
            resetSession();
        } finally {
            lock.unlock();
        }
    }

    // ── Admin operations ───────────────────────────────────────

    public void restock(String slotCode, int units) {
        lock.lock();
        try { inventory.restock(slotCode, units); }
        finally { lock.unlock(); }
    }

    public void setOutOfService(boolean outOfService) {
        state = outOfService ? MachineState.OUT_OF_SERVICE : MachineState.IDLE;
    }

    public List<Product> getAvailableProducts() {
        return inventory.getAvailableProducts();
    }

    public List<Transaction> getTransactionLog() {
        return Collections.unmodifiableList(transactionLog);
    }

    // ── Internals ──────────────────────────────────────────────

    private void resetSession() {
        this.selectedProduct    = null;
        this.activeProcessor    = null;
        this.currentTransaction = null;
        this.state              = MachineState.IDLE;
    }

    public Inventory     getInventory()  { return inventory; }
    public MachineState  getState()      { return state; }
    public String        getMachineId()  { return machineId; }
}

The demo covers the four key scenarios: cash purchase with change, card purchase, a cancelled transaction, and an admin restock operation.

// ── Demo ───────────────────────────────────────────────────────
public class Main {
    public static void main(String[] args) {
        // Reset singleton for demo (not needed in production)
        VendingMachine vm = VendingMachine.getInstance("VM-HQ-01");

        // Stock the machine
        Inventory inv = vm.getInventory();
        inv.addSlot("A1", new Product("P001", "Lays Classic",  20.0, ProductCategory.SNACK,    10));
        inv.addSlot("A2", new Product("P002", "Coca-Cola 330", 35.0, ProductCategory.BEVERAGE,  8));
        inv.addSlot("B1", new Product("P003", "KitKat",        25.0, ProductCategory.CANDY,     5));
        inv.addSlot("B2", new Product("P004", "Maggi Cup",     45.0, ProductCategory.MEAL,      3));

        System.out.println("=== Cash Purchase ===");
        vm.selectProduct("A2");               // Coca-Cola ₹35
        CashProcessor cash = new CashProcessor();
        cash.insertCash(50.0);               // Customer inserts ₹50
        vm.choosePayment(cash);
        Transaction t1 = vm.completePayment(50.0);
        System.out.println("Status: " + t1.getStatus());  // SUCCESS

        System.out.println("
=== Card Purchase ===");
        vm.selectProduct("A1");               // Lays ₹20
        vm.choosePayment(new CardProcessor());
        Transaction t2 = vm.completePayment(20.0);
        System.out.println("Status: " + t2.getStatus());  // SUCCESS

        System.out.println("
=== Cancel Flow ===");
        vm.selectProduct("B1");               // KitKat
        vm.cancel();                          // Change mind
        System.out.println("State: " + vm.getState()); // IDLE

        System.out.println("
=== Admin Restock ===");
        vm.restock("B1", 20);

        System.out.println("
Total transactions: " + vm.getTransactionLog().size());
    }
}

Expected output:

=== Cash Purchase ===

Selected: Coca-Cola 330 (₹35.00)

Payment method: CASH

Dispensing change: ₹15.00

Dispensing: Coca-Cola 330. Change: ₹15.00

Status: SUCCESS

=== Card Purchase ===

Selected: Lays Classic (₹20.00)

Charging card ₹20.00 (ref: abc-123...)

Dispensing: Lays Classic. Change: ₹0.00

Status: SUCCESS

=== Cancel Flow ===

Selected: KitKat (₹25.00)

Transaction cancelled.

State: IDLE

=== Admin Restock ===

Restocked KitKat in slot B1 (+20 units)

Total transactions: 2

Concurrency is a frequent follow-up. Here are the specific race conditions the locking strategy prevents:

Race Condition: Double-Dispense

Without locking: Thread A calls selectProduct("A1") and sees quantity=1. Before Thread A reaches completePayment(), Thread B also calls selectProduct("A1"), also sees quantity=1, and both complete payment. decrementStock() would go to -1, and both get the product.

✓ Fix: The full select→pay→dispense sequence is wrapped in a ReentrantLock, making the check-then-act atomic.

Race Condition: Concurrent State Transition

Without locking: Thread A calls choosePayment() and sets state to PAYMENT_PENDING. Thread B simultaneously calls cancel() and resets state to IDLE. Thread A then calls completePayment() — state is IDLE, so it fails validation. But Thread A already has a Transaction object referencing the product.

✓ Fix: All state-mutating operations acquire the lock before reading or writing state.

Singleton Initialization Race

Without double-checked locking, two threads could both observe _instance == null and both construct a VendingMachine, each with their own empty inventory and transaction log — two machines, neither aware of the other.

✓ Fix: volatile + synchronized (Java) / threading.Lock (Python) ensures single construction.

OperationTimeNotes
selectProduct(slotCode)O(1)HashMap lookup on slot code
completePayment(amount)O(1)decrementStock() + append to log
restock(slotCode, units)O(1)HashMap lookup + integer addition
findByProductId(id)O(1)Reverse index lookup
availableProducts()O(S)S = number of slots; full scan
getTransactionLog()O(1)Unmodifiable view of the list
Space complexity: O(S) for the slot map and reverse index, O(T) for the transaction log where T grows unboundedly. In production, the log would be periodically flushed to a database to bound memory usage.

Mention 2–3 of these to show proactive thinking. Don't implement them — discuss the approach.

Temperature-Controlled Slots

Subclass Slot → RefrigeratedSlot with a temperatureZone field. Add a capability filter to findBySlotCode() for drinks/meals requiring cold storage.

Age-Restricted Products

Add an ageRestricted flag on Product. selectProduct() checks it; if restricted, prompt for age verification (biometric / card swipe) before proceeding.

Remote Inventory Dashboard

Emit events on stock changes (Observer pattern). A remote dashboard subscribes via WebSocket to display real-time availability and trigger restock alerts.

Loyalty / Points System

Wrap PaymentProcessor in a LoyaltyDecorator. On each successful transaction, award points and deduct them on future purchases as partial payment.

Multi-Machine Network

Extract VendingMachine to a stateless service. Shared inventory state lives in Redis with optimistic locking. Each machine instance reads/writes centrally.

Expiry & Auto-Removal

Add expiryDate on Product. A background scheduler runs nightly, scans inventory, and removes expired products — marking those slots as unavailable.

16. Key Design Decisions

State Machine for Machine Flow

MachineState (IDLE → PRODUCT_SELECTED → PAYMENT_PENDING → DISPENSING → IDLE) prevents invalid transitions — e.g., paying before selecting, or dispensing without payment. Each action validates the current state before proceeding.

Strategy Pattern for Payment

PaymentProcessor is an interface with concrete implementations (Cash, Card, Wallet). Adding a new payment method (e.g., crypto, UPI QR) requires zero changes to VendingMachine — just implement PaymentProcessor.

Dual-index Inventory (slot + product)

The Inventory maintains both a slotCode→Slot map and a productId→Slot reverse index. Customers select by slot code (O(1)), while admins can look up by product ID (O(1)) for restocking. This is a space-vs-time tradeoff.

Slot-code vs. product-ID selection

We allow selection only by slot code (physical label on machine), not by product name. This is simpler and avoids ambiguity when the same product occupies multiple slots. A real UI layer can offer product search on top.

17. Common Follow-up Interview Questions

  • How would you handle a machine that runs out of change for cash payments?

    💡 Track total coins/notes in a CashFloat object. Before accepting cash payment, check if sufficient change is available; reject if not.

  • How do you model the physical coin insertion — e.g., customer inserts ₹10 twice?

    💡 CashProcessor.insertCash() is additive — call it multiple times. The machine stays in PAYMENT_PENDING until enough is inserted, then customer presses "buy".

  • What if the same product is stocked in multiple slots?

    💡 selectProduct(productId) can scan the productIndex and return the first available slot. Alternatively, expose a selectBySlot(code) and let the UI guide users.

  • How do you handle a dispensing motor failure after payment is taken?

    💡 Wrap dispensing in try-catch. On failure, call processRefund() and mark transaction as REFUNDED. Set machine to OUT_OF_SERVICE for the affected slot.

  • How would you add a display screen showing product details and price?

    💡 Observer pattern — VendingMachine emits SelectionEvent, DisplayController subscribes and re-renders. Decoupled from core state machine.

  • How do you implement a loyalty/points system?

    💡 Decorator pattern around PaymentProcessor — wraps the real processor, awards points on success, deducts points as partial payment if applicable.

  • How would you scale this to a fleet of 10,000 machines across a city?

    💡 Stateless service + Redis for shared inventory/state. Event sourcing for transaction log. Each machine becomes a thin client. Distributed locking (Redlock) for concurrent purchases.

  • How do you write unit tests for the state machine?

    💡 Use parameterized tests for valid/invalid transitions. Mock PaymentProcessor to simulate failures. Inject a fixed clock for deterministic timing in integration tests.