Low Level Design
Design a Coffee Vending Machine
A complete low-level design walkthrough for a coffee vending machine — Singleton services, Factory for coffee types, synchronized thread safety, and inventory tracking with low-stock alerts.
Problem Description#
Design a Coffee Vending Machine that serves multiple coffee types, processes payments, tracks ingredient inventory, and handles concurrent user requests safely.
At its core, a vending machine is a resource-coordination problem:
- Multiple users can request coffee simultaneously — shared state (ingredients, payment) must be thread-safe
- Different coffee types have different recipes and prices — creation logic should be centralized
- Inventory must be tracked and warned when running low
- Payment must validate amount and return change
This problem tests Singleton coordination, Factory-pattern object creation, polymorphism via interfaces, and synchronized concurrency — all at once.
Clarify Requirements#
Before designing, ask these in an interview:
Functional
- What coffee types are supported? Fixed menu or dynamic?
- What ingredients does each coffee need, and in what quantities?
- How is payment handled — exact change, overpayment with change returned?
- Who refills ingredients — automatic or manual admin action?
- Should low-stock be logged, alerting, or blocking?
Non-functional
- Can multiple users order simultaneously? (concurrency required?)
- Should inventory persist across restarts (database), or in-memory only?
- Is the menu configurable at runtime or compile-time?
Final Requirements#
After clarification, here's what we'll build:
- Three coffee types: Espresso ($5), Cappuccino ($10), Latte ($20) — each with a unique ingredient map
- A CoffeeFactory centralizes creation — one switch handles all types
- Inventory is a Singleton tracking ingredient stock; warns at ≤ 2 units
- PaymentService is a Singleton that validates payment and returns change
- VendingMachine is a Singleton facade: validates stock → payment → dispenses → updates inventory, all inside a synchronized (inventory) block
- Concurrent user requests are simulated with multiple threads — each order is fully thread-safe
Core Entities#
| Entity | Responsibility |
|---|---|
| CoffeeProduct | Interface — makeCoffee(), getPrice(), getIngredients() |
| Espresso | Implements CoffeeProduct; 2 coffee shots + 1 water, $5 |
| Cappuccino | Implements CoffeeProduct; 1 shot + 1 milk + 1 water, $10 |
| Latte | Implements CoffeeProduct; 1 shot + 1 milk + 1 water, $20 |
| CoffeeFactory | Static factory — creates the right CoffeeProduct from a string |
| Inventory | Singleton — tracks ingredient stock, checks availability, logs usage, warns on low stock |
| PaymentService | Singleton — validates paid amount, prints change or error |
| VendingMachine | Singleton facade — orchestrates the full order flow; makeCoffee() is thread-safe |
Patterns Used#
1. Singleton — VendingMachine, Inventory, PaymentService#
All three shared service classes use the classic double-checked locking singleton. There must be exactly one vending machine, one inventory, and one payment service — multiple instances would cause split-brain inventory counts and inconsistent payment state.
2. Factory — CoffeeFactory#
CoffeeFactory.getCoffee(String type) centralizes object creation. Adding a new coffee type (e.g., Mocha) requires only a new class + one new case in the factory — nothing else changes. Follows the Open/Closed Principle.
3. Interface / Polymorphism — CoffeeProduct#
VendingMachine works against CoffeeProduct only — it calls getPrice(), getIngredients(), and makeCoffee() without knowing the concrete type. This enables adding new drinks without touching the vending machine logic (Liskov Substitution).
4. Thread Safety — synchronized#
VendingMachine.makeCoffee() wraps the entire check-payment-dispense-update sequence in synchronized (inventory). This ensures that two concurrent users can't both pass the checkIngredients gate and then both drain the same stock. Inventory.getInstance() and VendingMachine.getInstance() are also synchronized for safe lazy initialization.
Code#
Coffee Products — Interface & Implementations#
CoffeeProduct defines the contract. Each concrete type declares its own price and ingredient map in its constructor — no recipe logic leaks elsewhere.
import java.util.Map;
public interface CoffeeProduct {
void makeCoffee();
int getPrice();
Map<String, Integer> getIngredients();
}import java.util.HashMap;
import java.util.Map;
class Espresso implements CoffeeProduct {
private final int price = 5;
private final Map<String, Integer> ingredients = new HashMap<>();
Espresso() {
ingredients.put("coffeeShot", 2);
ingredients.put("Water", 1);
}
@Override public void makeCoffee() { System.out.println("Making Espresso..."); }
@Override public int getPrice() { return price; }
@Override public Map<String, Integer> getIngredients() { return ingredients; }
}import java.util.HashMap;
import java.util.Map;
class Cappuccino implements CoffeeProduct {
private final int price = 10;
private final Map<String, Integer> ingredients = new HashMap<>();
Cappuccino() {
ingredients.put("coffeeShot", 1);
ingredients.put("Milk", 1);
ingredients.put("Water", 1);
}
@Override public void makeCoffee() { System.out.println("Making Cappuccino..."); }
@Override public int getPrice() { return price; }
@Override public Map<String, Integer> getIngredients() { return ingredients; }
}import java.util.HashMap;
import java.util.Map;
class Latte implements CoffeeProduct {
private final int price = 20;
private final Map<String, Integer> ingredients = new HashMap<>();
Latte() {
ingredients.put("coffeeShot", 1);
ingredients.put("Milk", 1);
ingredients.put("Water", 1);
}
@Override public void makeCoffee() { System.out.println("Making Latte..."); }
@Override public int getPrice() { return price; }
@Override public Map<String, Integer> getIngredients() { return ingredients; }
}Factory#
CoffeeFactory is a pure static utility — one switch, no instance required. The caller never news a coffee type directly.
public class CoffeeFactory {
public static CoffeeProduct getCoffee(String type) {
return switch (type.toLowerCase()) {
case "espresso" -> new Espresso();
case "cappuccino" -> new Cappuccino();
case "latte" -> new Latte();
default -> throw new IllegalArgumentException("Unknown coffee type: " + type);
};
}
}
Services — Inventory & Payment#
Inventory is the sole authority over ingredient stock. useIngredients deducts stock and emits a ⚠️ warning below the low-stock threshold.
PaymentService handles the payment math in one place.
import java.util.HashMap;
import java.util.Map;
class Inventory {
private static final int LOW_STOCK_THRESHOLD = 2;
private static Inventory inventory;
private final Map<String, Integer> ingredients = new HashMap<>();
private Inventory() {
ingredients.put("coffeeShot", 10);
ingredients.put("Milk", 3);
ingredients.put("Water", 10);
}
public static synchronized Inventory getInstance() {
if (inventory == null) inventory = new Inventory();
return inventory;
}
public boolean checkIngredients(Map<String, Integer> required) {
for (Map.Entry<String, Integer> e : required.entrySet()) {
if (ingredients.getOrDefault(e.getKey(), 0) < e.getValue()) return false;
}
return true;
}
public void useIngredients(Map<String, Integer> used) {
for (Map.Entry<String, Integer> e : used.entrySet()) {
int newStock = ingredients.get(e.getKey()) - e.getValue();
ingredients.put(e.getKey(), newStock);
if (newStock <= LOW_STOCK_THRESHOLD)
System.out.println("⚠️ Low stock: " + e.getKey() + " (" + newStock + " left)");
}
}
public void refill() {
ingredients.put("coffeeShot", 10);
ingredients.put("Milk", 10);
ingredients.put("Water", 10);
}
public void addIngredients(String ingredient, int qty) {
ingredients.merge(ingredient, qty, Integer::sum);
}
}class PaymentService {
private static PaymentService payment;
private PaymentService() {}
public static synchronized PaymentService getInstance() {
if (payment == null) payment = new PaymentService();
return payment;
}
public void processPayment(int paid, int price) {
if (paid > price)
System.out.println("Payment successful. Change returned: $" + (paid - price));
else if (paid == price)
System.out.println("Payment successful. No change.");
else
System.out.println("Insufficient payment. Need at least $" + price);
}
}Vending Machine — Singleton Facade#
makeCoffee synchronizes on the shared inventory object to prevent two threads from both passing the stock check before either deducts ingredients.
public class VendingMachine {
private static VendingMachine vendingMachine;
private final Inventory inventory;
private final PaymentService payment;
private VendingMachine() {
this.inventory = Inventory.getInstance();
this.payment = PaymentService.getInstance();
}
public static synchronized VendingMachine getInstance() {
if (vendingMachine == null) vendingMachine = new VendingMachine();
return vendingMachine;
}
public void showMenu() {
System.out.println("Menu: Espresso $5 | Cappuccino $10 | Latte $20");
}
public void makeCoffee(String coffeeType, int amount) {
String user = Thread.currentThread().getName();
System.out.println("\n[" + user + "] Ordering: " + coffeeType);
CoffeeProduct coffee;
try {
coffee = CoffeeFactory.getCoffee(coffeeType);
} catch (IllegalArgumentException e) {
System.out.println("[" + user + "] Unknown coffee type: " + coffeeType);
return;
}
synchronized (inventory) {
if (!inventory.checkIngredients(coffee.getIngredients())) {
System.out.println("[" + user + "] Insufficient ingredients for " + coffeeType);
return;
}
if (amount < coffee.getPrice()) {
System.out.println("[" + user + "] Insufficient payment.");
return;
}
coffee.makeCoffee();
payment.processPayment(amount, coffee.getPrice());
inventory.useIngredients(coffee.getIngredients());
System.out.println("✅ " + coffeeType + " ready for " + user + "!");
}
}
}
Demo — Concurrent Requests#
Four threads fire simultaneously — thread safety ensures inventory is consistent and no order double-deducts.
public class CoffeeVendingMachine {
public static void main(String[] args) {
VendingMachine vm = VendingMachine.getInstance();
vm.showMenu();
Thread user1 = new Thread(() -> vm.makeCoffee("Espresso", 10), "User-1");
Thread user2 = new Thread(() -> vm.makeCoffee("Latte", 20), "User-2");
Thread user3 = new Thread(() -> vm.makeCoffee("Cappuccino", 15), "User-3");
Thread user4 = new Thread(() -> vm.makeCoffee("Invalid", 10), "User-4");
user1.start(); user2.start(); user3.start(); user4.start();
}
}
Class Diagram#
Extendible — Follow Ups#
1. Add new coffee types without touching existing code#
Add a Mocha class implementing CoffeeProduct, then add one case "mocha" line in CoffeeFactory. VendingMachine and Inventory need no changes — pure Open/Closed extension.
2. State machine for machine lifecycle#
Replace the free-form makeCoffee method with explicit states: IdleState → PaymentState → BrewingState → DispenseState. Each state implements a MachineState interface. VendingMachine becomes the context — transitions are explicit and testable.
3. Observer for low-stock alerts#
When Inventory.useIngredients drops a stock below threshold, publish a LowStockEvent. An AdminNotifier subscriber logs it or sends an SMS. Removes the System.out.println coupling from inventory logic.
4. Decorator for coffee customizations#
ExtraShotDecorator(CoffeeProduct) wraps a base coffee and adds 1 coffee shot to its ingredient map and +$2 to its price. Chain decorators freely: new ExtraShotDecorator(new SugarDecorator(new Latte())).
5. Command pattern for queued orders#
Wrap each order in a BrewCommand(coffeeType, amount, user). Queue commands in a BlockingQueue<BrewCommand> and process them with a single worker thread — eliminates the need for synchronized on inventory, as all orders become sequential.
6. Persistent inventory#
Replace the in-memory HashMap in Inventory with a InventoryRepository interface. Swap InMemoryInventoryRepository for a RedisInventoryRepository in production without touching VendingMachine.