Code · LLD machine coding · PhonePe prep
Vending Machine
Design a vending machine.
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());
}
}