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.

August 11, 2026·15 min read

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#

EntityResponsibility
CoffeeProductInterface — makeCoffee(), getPrice(), getIngredients()
EspressoImplements CoffeeProduct; 2 coffee shots + 1 water, $5
CappuccinoImplements CoffeeProduct; 1 shot + 1 milk + 1 water, $10
LatteImplements CoffeeProduct; 1 shot + 1 milk + 1 water, $20
CoffeeFactoryStatic factory — creates the right CoffeeProduct from a string
InventorySingleton — tracks ingredient stock, checks availability, logs usage, warns on low stock
PaymentServiceSingleton — validates paid amount, prints change or error
VendingMachineSingleton 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.

java
import java.util.Map;

public interface CoffeeProduct {
    void makeCoffee();
    int getPrice();
    Map<String, Integer> getIngredients();
}

Factory#

CoffeeFactory is a pure static utility — one switch, no instance required. The caller never news a coffee type directly.

java
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.

java
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);
    }
}

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.

java
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.

java
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.