Code · LLD machine coding · PhonePe prep

Vending Machine

Design a vending machine.

16 classes 443 lines StateCustom exceptionsService layer View on GitHub ↗

Question

Design a vending machine.

  • Admin restocks products and loads coins for change, and can collect the cash.
  • A user selects a product, inserts coins/notes, and gets the product plus change broken into denominations.
  • Errors: out of stock, not enough money, no change available, unknown product.
  • The user can cancel and get a refund.

Design: State pattern (Idle → ProductSelected → Dispensing), CashBox for change-making by denomination, Inventory, custom exceptions, AdminService. Run VendingMachineDemo.

VendingMachine.java 76 lines GitHub ↗
package com.practice.phonepe.questions.vendingmachine;

import com.practice.phonepe.questions.vendingmachine.entities.CashBox;
import com.practice.phonepe.questions.vendingmachine.entities.Denomination;
import com.practice.phonepe.questions.vendingmachine.entities.DispenseResult;
import com.practice.phonepe.questions.vendingmachine.entities.Inventory;
import com.practice.phonepe.questions.vendingmachine.state.IdleState;
import com.practice.phonepe.questions.vendingmachine.state.VendingMachineState;

import java.util.Collections;
import java.util.List;

/** State-pattern context. Every user action is forwarded to whichever state is currently active. */
public class VendingMachine {

    private final Inventory inventory = new Inventory();
    private final CashBox cashBox = new CashBox();

    private VendingMachineState currentState = new IdleState();
    private String selectedProductCode;
    private int amountPaid;

    // --- user-facing actions ---

    public void selectProduct(String productCode) {
        currentState.selectProduct(this, productCode);
    }

    public void insertCoin(Denomination denomination) {
        currentState.insertMoney(this, denomination.getValue());
        cashBox.deposit(denomination);
    }

    public DispenseResult dispense() {
        return currentState.dispense(this);
    }

    public void cancel() {
        currentState.cancel(this);
    }

    // --- exposed for AdminService ---

    public Inventory getInventory() {
        return inventory;
    }

    public CashBox getCashBox() {
        return cashBox;
    }

    // --- accessors the states operate on ---

    public void setState(VendingMachineState state) {
        this.currentState = state;
    }

    public VendingMachineState getState() {
        return currentState;
    }

    public String getSelectedProductCode() {
        return selectedProductCode;
    }

    public void setSelectedProductCode(String selectedProductCode) {
        this.selectedProductCode = selectedProductCode;
    }

    public int getAmountPaid() {
        return amountPaid;
    }

    public void addAmountPaid(int amount) {
        this.amountPaid += amount;
    }

    public void resetPayment() {
        this.amountPaid = 0;
        this.selectedProductCode = null;
    }

    /** Converts change owed into physical denominations pulled from the cash float. */
    public List<Denomination> settleChange(int changeDue) {
        if (changeDue <= 0) {
            return Collections.emptyList();
        }
        return cashBox.makeChange(changeDue);
    }

    /** Refunds whatever cash has been inserted so far when a transaction is cancelled mid-payment. */
    public void refundCurrentPayment() {
        if (amountPaid <= 0) {
            return;
        }
        List<Denomination> refunded = cashBox.makeChange(amountPaid);
        System.out.println("Refunded cash: " + refunded);
    }
}
entities/CashBox.java 51 lines GitHub ↗
package com.practice.phonepe.questions.vendingmachine.entities;

import com.practice.phonepe.questions.vendingmachine.exception.ChangeNotAvailableException;

import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;

/**
 * The machine's physical cash float. Coins/notes a user inserts are deposited here and become
 * available to hand back out as change to the next customer.
 */
public class CashBox {

    private final Map<Denomination, Integer> countByDenomination = new EnumMap<>(Denomination.class);

    public void deposit(Denomination denomination) {
        deposit(denomination, 1);
    }

    public void deposit(Denomination denomination, int count) {
        countByDenomination.merge(denomination, count, Integer::sum);
    }

    /** Greedily breaks {@code amount} into the largest denominations currently on hand. */
    public List<Denomination> makeChange(int amount) {
        List<Denomination> change = new ArrayList<>();
        int remaining = amount;
        Denomination[] values = Denomination.values();
        for (int i = values.length - 1; i >= 0 && remaining > 0; i--) {
            Denomination denomination = values[i];
            int available = countByDenomination.getOrDefault(denomination, 0);
            int used = Math.min(available, remaining / denomination.getValue());
            for (int j = 0; j < used; j++) {
                change.add(denomination);
            }
            remaining -= used * denomination.getValue();
        }
        if (remaining > 0) {
            throw new ChangeNotAvailableException("Cannot make change for " + amount + " with the cash currently in the machine");
        }
        change.forEach(denomination -> countByDenomination.merge(denomination, -1, Integer::sum));
        return change;
    }

    public Map<Denomination, Integer> collectAll() {
        Map<Denomination, Integer> snapshot = new EnumMap<>(countByDenomination);
        countByDenomination.clear();
        return snapshot;
    }

    public int getTotalValue() {
        int total = 0;
        for (Map.Entry<Denomination, Integer> entry : countByDenomination.entrySet()) {
            total += entry.getKey().getValue() * entry.getValue();
        }
        return total;
    }
}
entities/Denomination.java 18 lines GitHub ↗
package com.practice.phonepe.questions.vendingmachine.entities;

/** Covers coins and notes alike - the machine only needs each one's value, not its physical form. */
public enum Denomination {
    ONE(1),
    TWO(2),
    FIVE(5),
    TEN(10),
    TWENTY(20),
    FIFTY(50),
    HUNDRED(100);

    private final int value;

    Denomination(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }
}
entities/DispenseResult.java 21 lines GitHub ↗
package com.practice.phonepe.questions.vendingmachine.entities;

import java.util.List;

public class DispenseResult {

    private final String productCode;
    private final int changeAmount;
    private final List<Denomination> changeBreakdown;

    public DispenseResult(String productCode, int changeAmount, List<Denomination> changeBreakdown) {
        this.productCode = productCode;
        this.changeAmount = changeAmount;
        this.changeBreakdown = changeBreakdown;
    }

    public String getProductCode() {
        return productCode;
    }

    public int getChangeAmount() {
        return changeAmount;
    }

    public List<Denomination> getChangeBreakdown() {
        return changeBreakdown;
    }
}
entities/Inventory.java 43 lines GitHub ↗
package com.practice.phonepe.questions.vendingmachine.entities;

import com.practice.phonepe.questions.vendingmachine.exception.ProductNotFoundException;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/** Tracks which products the machine carries and how many of each are left. */
public class Inventory {

    private final Map<String, Product> productsByCode = new LinkedHashMap<>();
    private final Map<String, Integer> quantityByCode = new LinkedHashMap<>();

    public void addOrRestock(Product product, int quantity) {
        productsByCode.put(product.getCode(), product);
        quantityByCode.merge(product.getCode(), quantity, Integer::sum);
    }

    public boolean exists(String productCode) {
        return productsByCode.containsKey(productCode);
    }

    public boolean hasStock(String productCode) {
        return quantityByCode.getOrDefault(productCode, 0) > 0;
    }

    public Product getProduct(String productCode) {
        Product product = productsByCode.get(productCode);
        if (product == null) {
            throw new ProductNotFoundException("No such product: " + productCode);
        }
        return product;
    }

    public int getQuantity(String productCode) {
        return quantityByCode.getOrDefault(productCode, 0);
    }

    public void decrement(String productCode) {
        quantityByCode.merge(productCode, -1, Integer::sum);
    }

    public List<Product> listAvailableProducts() {
        List<Product> available = new ArrayList<>();
        for (Product product : productsByCode.values()) {
            if (hasStock(product.getCode())) {
                available.add(product);
            }
        }
        return available;
    }
}
entities/Product.java 20 lines GitHub ↗
package com.practice.phonepe.questions.vendingmachine.entities;

public class Product {

    private final String code;
    private final String name;
    private final int price;

    public Product(String code, String name, int price) {
        this.code = code;
        this.name = name;
        this.price = price;
    }

    public String getCode() {
        return code;
    }

    public String getName() {
        return name;
    }

    public int getPrice() {
        return price;
    }
}
exception/ChangeNotAvailableException.java 6 lines GitHub ↗
package com.practice.phonepe.questions.vendingmachine.exception;

public class ChangeNotAvailableException extends RuntimeException {
    public ChangeNotAvailableException(String message) {
        super(message);
    }
}
exception/InsufficientMoneyException.java 6 lines GitHub ↗
package com.practice.phonepe.questions.vendingmachine.exception;

public class InsufficientMoneyException extends RuntimeException {
    public InsufficientMoneyException(String message) {
        super(message);
    }
}
exception/OutOfStockException.java 6 lines GitHub ↗
package com.practice.phonepe.questions.vendingmachine.exception;

public class OutOfStockException extends RuntimeException {
    public OutOfStockException(String message) {
        super(message);
    }
}
exception/ProductNotFoundException.java 6 lines GitHub ↗
package com.practice.phonepe.questions.vendingmachine.exception;

public class ProductNotFoundException extends RuntimeException {
    public ProductNotFoundException(String message) {
        super(message);
    }
}
service/AdminService.java 24 lines GitHub ↗
package com.practice.phonepe.questions.vendingmachine.service;

import com.practice.phonepe.questions.vendingmachine.VendingMachine;
import com.practice.phonepe.questions.vendingmachine.entities.Denomination;
import com.practice.phonepe.questions.vendingmachine.entities.Product;

import java.util.Map;

/** The operator-facing surface: load stock, seed the change float, and collect cash out of it. */
public class AdminService {

    private final VendingMachine machine;

    public AdminService(VendingMachine machine) {
        this.machine = machine;
    }

    public void restock(String productCode, String name, int price, int quantity) {
        machine.getInventory().addOrRestock(new Product(productCode, name, price), quantity);
    }

    public void loadChangeFloat(Denomination denomination, int count) {
        machine.getCashBox().deposit(denomination, count);
    }

    public Map<Denomination, Integer> collectCash() {
        return machine.getCashBox().collectAll();
    }

    public int getCashOnHand() {
        return machine.getCashBox().getTotalValue();
    }
}
state/DispensingState.java 40 lines GitHub ↗
package com.practice.phonepe.questions.vendingmachine.state;

import com.practice.phonepe.questions.vendingmachine.VendingMachine;
import com.practice.phonepe.questions.vendingmachine.entities.Denomination;
import com.practice.phonepe.questions.vendingmachine.entities.DispenseResult;
import com.practice.phonepe.questions.vendingmachine.exception.ChangeNotAvailableException;

import java.util.List;

/** Momentary state: payment has cleared, the machine is committed to releasing the product. */
public class DispensingState implements VendingMachineState {

    @Override
    public void selectProduct(VendingMachine machine, String productCode) {
        throw new IllegalStateException("Machine is busy dispensing");
    }

    @Override
    public void insertMoney(VendingMachine machine, int amount) {
        throw new IllegalStateException("Machine is busy dispensing");
    }

    @Override
    public DispenseResult dispense(VendingMachine machine) {
        String productCode = machine.getSelectedProductCode();
        int price = machine.getInventory().getProduct(productCode).getPrice();
        int changeDue = machine.getAmountPaid() - price;

        List<Denomination> change;
        try {
            change = machine.settleChange(changeDue);
        } catch (ChangeNotAvailableException e) {
            // Nothing has been committed yet - fall back to ProductSelectedState instead of
            // stranding the machine here, where every action would otherwise be illegal.
            machine.setState(new ProductSelectedState());
            throw e;
        }

        machine.getInventory().decrement(productCode);
        machine.resetPayment();
        machine.setState(new IdleState());
        return new DispenseResult(productCode, changeDue, change);
    }

    @Override
    public void cancel(VendingMachine machine) {
        throw new IllegalStateException("Cannot cancel while dispensing");
    }
}
state/IdleState.java 31 lines GitHub ↗
package com.practice.phonepe.questions.vendingmachine.state;

import com.practice.phonepe.questions.vendingmachine.VendingMachine;
import com.practice.phonepe.questions.vendingmachine.entities.DispenseResult;
import com.practice.phonepe.questions.vendingmachine.exception.OutOfStockException;
import com.practice.phonepe.questions.vendingmachine.exception.ProductNotFoundException;

/** Waiting for a selection. Nothing has been paid for yet. */
public class IdleState implements VendingMachineState {

    @Override
    public void selectProduct(VendingMachine machine, String productCode) {
        if (!machine.getInventory().exists(productCode)) {
            throw new ProductNotFoundException("No such product: " + productCode);
        }
        if (!machine.getInventory().hasStock(productCode)) {
            throw new OutOfStockException("Out of stock: " + productCode);
        }
        machine.setSelectedProductCode(productCode);
        machine.setState(new ProductSelectedState());
    }

    @Override
    public void insertMoney(VendingMachine machine, int amount) {
        throw new IllegalStateException("Select a product before inserting money");
    }

    @Override
    public DispenseResult dispense(VendingMachine machine) {
        throw new IllegalStateException("No product selected");
    }

    @Override
    public void cancel(VendingMachine machine) {
        // nothing in progress to cancel
    }
}
state/ProductSelectedState.java 35 lines GitHub ↗
package com.practice.phonepe.questions.vendingmachine.state;

import com.practice.phonepe.questions.vendingmachine.VendingMachine;
import com.practice.phonepe.questions.vendingmachine.entities.DispenseResult;
import com.practice.phonepe.questions.vendingmachine.exception.InsufficientMoneyException;

/**
 * A product is chosen and payment is accumulating. Coins/notes arrive one {@link #insertMoney}
 * call at a time.
 */
public class ProductSelectedState implements VendingMachineState {

    @Override
    public void selectProduct(VendingMachine machine, String productCode) {
        throw new IllegalStateException(
                "Already selected " + machine.getSelectedProductCode() + " - cancel first to pick another");
    }

    @Override
    public void insertMoney(VendingMachine machine, int amount) {
        machine.addAmountPaid(amount);
    }

    @Override
    public DispenseResult dispense(VendingMachine machine) {
        int price = machine.getInventory().getProduct(machine.getSelectedProductCode()).getPrice();
        if (machine.getAmountPaid() < price) {
            throw new InsufficientMoneyException(
                    "Need " + (price - machine.getAmountPaid()) + " more to dispense " + machine.getSelectedProductCode());
        }
        machine.setState(new DispensingState());
        return machine.dispense();
    }

    @Override
    public void cancel(VendingMachine machine) {
        machine.refundCurrentPayment();
        machine.resetPayment();
        machine.setState(new IdleState());
    }
}
state/VendingMachineState.java 9 lines GitHub ↗
package com.practice.phonepe.questions.vendingmachine.state;

import com.practice.phonepe.questions.vendingmachine.VendingMachine;
import com.practice.phonepe.questions.vendingmachine.entities.DispenseResult;

public interface VendingMachineState {
    void selectProduct(VendingMachine machine, String productCode);
    void insertMoney(VendingMachine machine, int amount);
    DispenseResult dispense(VendingMachine machine);
    void cancel(VendingMachine machine);
}
VendingMachineDemo.java 51 lines GitHub ↗
package com.practice.phonepe.questions.vendingmachine;

import com.practice.phonepe.questions.vendingmachine.entities.Denomination;
import com.practice.phonepe.questions.vendingmachine.entities.DispenseResult;
import com.practice.phonepe.questions.vendingmachine.exception.InsufficientMoneyException;
import com.practice.phonepe.questions.vendingmachine.exception.OutOfStockException;
import com.practice.phonepe.questions.vendingmachine.service.AdminService;

public class VendingMachineDemo {

    public static void main(String[] args) {
        VendingMachine machine = new VendingMachine();
        AdminService admin = new AdminService(machine);

        // admin stocks products and seeds the change float
        admin.restock("COKE", "Coke 500ml", 25, 1);
        admin.restock("CHIPS", "Lays Chips", 20, 0);
        admin.loadChangeFloat(Denomination.FIVE, 10);
        admin.loadChangeFloat(Denomination.TEN, 10);

        // cash purchase, paid in three coins, change returned in denominations
        machine.selectProduct("COKE");
        try {
            machine.dispense();
        } catch (InsufficientMoneyException e) {
            System.out.println("Expected failure: " + e.getMessage());
        }
        machine.insertCoin(Denomination.TEN);
        machine.insertCoin(Denomination.TEN);
        machine.insertCoin(Denomination.TEN);
        DispenseResult result = machine.dispense();
        System.out.printf("Dispensed %s, change %d = %s%n",
                result.getProductCode(), result.getChangeAmount(), result.getChangeBreakdown());

        // depleted item, then an item that was never stocked with any quantity
        try {
            machine.selectProduct("COKE");
        } catch (OutOfStockException e) {
            System.out.println("Expected failure: " + e.getMessage());
        }

        // restock, then pay exact change - no coins owed back
        admin.restock("CHIPS", "Lays Chips", 20, 2);
        machine.selectProduct("CHIPS");
        machine.insertCoin(Denomination.TWENTY);
        DispenseResult exactResult = machine.dispense();
        System.out.printf("Dispensed %s, change %d%n", exactResult.getProductCode(), exactResult.getChangeAmount());

        // cancel mid-payment - cash already inserted is refunded, stock untouched
        machine.selectProduct("CHIPS");
        machine.insertCoin(Denomination.TEN);
        machine.cancel();
        System.out.println("State after cancel: " + machine.getState().getClass().getSimpleName());

        // admin collects the accumulated cash float
        System.out.println("Cash on hand before collection: " + admin.getCashOnHand());
        System.out.println("Collected: " + admin.collectCash());
        System.out.println("Cash on hand after collection: " + admin.getCashOnHand());
    }
}