Low-Level Design: Designing a Vending Machine using the State Pattern
Build a robust, state-driven vending machine system in Java Boot with clean OOP design.

Abstract Algorithms
Helping engineers master software engineering topics.
TLDR: Designing a Vending Machine is a classic low-level design interview problem that tests state-driven logic. By using the GoF State Pattern, we avoid nested conditional blocks and build a modular, thread-safe system in Java Spring Boot.
📖 Design Challenge: The Fragile Vending Machine
Imagine you are tasked with developing the control software for a next-generation office vending machine. The machine must accept coins of different denominations, track a dynamic inventory of snacks, validate user selections, dispense products, and return change.
A naive approach might rely on nested conditional checks to manage the machine's state:
// VIOLATION: Nested conditional statements that become unmaintainable as states grow
public void insertCoin(Coin coin) {
if (state == IDLE) {
insertedMoney += coin.getValue();
state = HAS_MONEY;
} else if (state == OUT_OF_STOCK) {
throw new IllegalStateException("Machine is empty");
} else if (state == HAS_MONEY) {
insertedMoney += coin.getValue();
} else if (state == DISPENSING) {
throw new IllegalStateException("Currently dispensing");
}
}
This code is fragile. If the business requests a new state—such as "Refunding" or "Maintenance Mode"—you must edit every single method (insertCoin, selectProduct, dispense, refund) to add another conditional branch. This violates the Open-Closed Principle and makes testing state transitions a nightmare.
The solution is the State Pattern: encapsulate each state in a separate class that implements a common interface. The vending machine itself delegates behavior to its current state object, making state transitions clean and isolated.
🔍 Scope: Use Cases and System Actors
Before drawing class structures, we must establish the system boundary and identify the core functional requirements.
System Actors
- Customer: The primary user who inserts money, selects products, requests refunds, and collects snacks and change.
- Maintenance Technician: An administrative user who refills inventory, collects accumulated cash, and audits machine health.
Core Functional Requirements (Use Cases)
- Insert Money: The Customer inserts coins of recognized denominations (Nickel, Dime, Quarter, Dollar). The system updates the transaction balance.
- Select Product: The Customer chooses a product slot (e.g., "A1" for Soda). The system validates if the product is in stock and if the balance covers the price.
- Dispense Product: The Machine reduces stock, calculates change, drops the snack, and updates the state.
- Return Change / Cancel: The Customer cancels the transaction before purchasing, requesting their deposited money back.
- Restock & Collect Cash: The Technician refills slots and clears the coin repository.
📊 Architectural Blueprint: Class Diagram
The class diagram below maps the relationships between the main components of our vending machine design, detailing variables, methods, and interface dependencies.
classDiagram
VendingMachine "1" *-- "1" VendingState : delegates to
VendingMachine "1" *-- "1" Inventory : manages
VendingState <|.. IdleState : implements
VendingState <|.. HasMoneyState : implements
VendingState <|.. DispensingState : implements
VendingState <|.. OutOfStockState : implements
class VendingState {
<>
+insertCoin(VendingMachine machine, Coin coin) void
+selectProduct(VendingMachine machine, String slotId) void
+dispense(VendingMachine machine) void
+refund(VendingMachine machine) void
}
class VendingMachine {
-VendingState currentState
-Inventory inventory
-int balance
+setCurrentState(VendingState state) void
+insertCoin(Coin coin) void
+selectProduct(String slotId) void
+dispense() void
+refund() void
}
This class diagram illustrates the implementation of the State Pattern. The VendingMachine maintains a reference to a VendingState interface and delegates all actions to it. The four concrete state implementations—IdleState, HasMoneyState, DispensingState, and OutOfStockState—override these actions to execute state-specific logic and trigger transitions by calling VendingMachine.setCurrentState(). This design decouples state-specific rules from the main machine execution context.
⚙️ Core Mechanics: Mapping the OOP Pillars
Our design leverages the four core pillars of Object-Oriented Programming to ensure structural integrity:
- Abstraction: The
VendingStateinterface defines a boundary. It hides state-specific transition rules from theVendingMachineand the client. The client calls generic methods likeinsertCoinwithout knowing how the machine transitions internally. - Encapsulation: The
VendingMachineencapsulates its internal variables—such asbalance,selectedSlot, andinventory—restricting direct manipulation. State classes manipulate these variables only through public setters and getters, keeping data access highly controlled. - Inheritance: Concrete states inherit interface contracts from
VendingState, ensuring standard method signatures. - Polymorphism: The
VendingMachineexecutes actions polymorphically. The linecurrentState.insertCoin(this, coin)executes completely different behavior depending on the runtime type ofcurrentState.
🧠 Deep Dive: SOLID Walkthrough and Concurrency
Let us walk through how our architecture implements SOLID design principles and handles concurrency in production.
The Internals of State-Driven Execution
Our vending machine design enforces multiple SOLID principles to maintain high codebase maintainability:
- Single Responsibility Principle (SRP): Each concrete state class has a single responsibility: managing the actions permitted in that specific state. For example,
IdleStateonly handles the initial coin insertion, delegating product selection warnings back to the client. - Open-Closed Principle (OCP): Introducing a new state (e.g.,
MaintenanceStateorCardAuthorizationState) only requires writing a new class that implementsVendingStateand updating the transitions. We do not need to modify existing state classes or introduce nested switch-case blocks. - Liskov Substitution Principle (LSP): All state classes can be substituted for the
VendingStateinterface safely. They do not throw unexpected runtime exceptions; they either execute the action or log a valid state violation. - Interface Segregation Principle (ISP): The
VendingStateinterface is focused only on machine transitions, while theInventoryremains decoupled from the payment channels.
Performance Analysis of In-Memory State Objects and Concurrency
In a high-throughput environment, allocating new state instances during every transition creates garbage collection overhead. To optimize this, we implement concrete states as stateless singletons.
Instead of storing variables inside the state instances, we pass the VendingMachine context as an argument to every state method: insertCoin(VendingMachine machine, Coin coin). This allows a single instance of IdleState to be shared across thousands of vending machines concurrently, reducing heap allocations and optimizing JVM memory footprint.
Concurrency is a critical challenge. If two threads invoke insertCoin or selectProduct on the same VendingMachine instance simultaneously, they can create race conditions on the balance variable or allow double-allocation of stock. To prevent this, we declare the context methods on the VendingMachine (like insertCoin, selectProduct, and refund) as synchronized.
For higher-scale environments with heavy stock queries, we replace synchronized blocks on the inventory list with ConcurrentHashMap and thread-safe primitive wrappers like AtomicInteger for stock count tracking, ensuring lock-free read access to snack availability.
📊 Visualizing State Transitions: The Vending Flow
To understand how the machine behaves as the user interacts with it, we map the lifecycle of a transaction. The diagram below shows the paths between states based on trigger actions.
flowchart TD
Idle([Idle State]) -->|insertCoin| HasMoney([Has Money State])
HasMoney -->|insertCoin| HasMoney
HasMoney -->|selectProduct - balance ok| Dispensing([Dispensing State])
HasMoney -->|refund / cancel| Idle
Dispensing -->|dispense completed| Idle
Dispensing -->|dispense - empty| OutOfStock([Out of Stock State])
OutOfStock -->|refund| Idle
This state transition flowchart maps the operational phases of the vending machine. The system begins in the Idle state. Inserting a coin transitions the machine to Has Money. From here, the user can either insert more coins, cancel the transaction to get a refund (which returns the machine to Idle), or select a product. If a valid product is chosen, the machine enters Dispensing. Once dispensing completes, the system returns to Idle (or transitions to OutOfStock if all items are depleted).
🌍 Real-World Endpoint: Spring REST API
To expose our LLD simulation to the client, we implement a Spring Boot @RestController mapping endpoints to the VendingMachine singleton.
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/vending")
public class VendingMachineController {
private final VendingMachine machine = new VendingMachine();
@GetMapping("/status")
public VendingStatus getStatus() {
return new VendingStatus(machine.getBalance(), machine.getSelectedSlot());
}
@PostMapping("/insert")
public String insertCoin(@RequestParam Coin coin) {
machine.insertCoin(coin);
return "Balance updated: " + machine.getBalance();
}
@PostMapping("/select")
public String selectProduct(@RequestParam String slotId) {
machine.selectProduct(slotId);
return "Selection processed.";
}
@PostMapping("/refund")
public String refund() {
machine.refund();
return "Refund complete.";
}
}
class VendingStatus {
private final int balance;
private final String selectedSlot;
public VendingStatus(int balance, String selectedSlot) {
this.balance = balance;
this.selectedSlot = selectedSlot;
}
public int getBalance() { return balance; }
public String getSelectedSlot() { return selectedSlot; }
}
This controller handles coin insertions, product selections, and cancel requests, executing state transitions concurrently inside Spring container threads.
⚖️ Trade-offs and Failure Modes: State Pattern Overhead
Every architectural choice has trade-offs:
- Class Proliferation: The State Pattern requires creating a new class for every single state. For small machines with only 2 or 3 states, this is often over-engineering.
- Tight Coupling of Transitions: State classes must know about other state classes to trigger transitions (e.g.,
IdleStatetransitions toHasMoneyState). This introduces compile-time dependencies between state subclasses. - Context Exposure: Since concrete state classes must invoke state transition functions on the context class (
VendingMachine.setCurrentState()), the context is forced to expose public methods for state manipulation that could be abused by other non-state classes in the package.
🧭 Decision and Extension: Adding Card Payments
Suppose the business introduces a new requirement: the machine must accept credit card payments.
Because our design segregates interfaces, we do not need to modify our existing coin-based state logic. We can introduce a new payment method abstraction. The table below guides our design decisions relative to scaling this payment layer.
| Metric | Cash-Only State Machine | Card Integration State Machine |
| State Transitions | Local validation, immediate transition | Async external gateway confirmation required |
| Concurrency risk | Low (single hardware slot) | High (parallel gateway calls) |
| Complexity | Low (synchronous) | Medium (requires async wait states) |
| LSP adherence | High | High (abstracted behind PaymentMethod interface) |
We can extend the system simply by injecting a PaymentProcessor interface into the HasMoneyState class, adding card support without modifying the core state loop.
🧪 Implementation: Full Java Domain Model
Here is the complete Java domain model for our Vending Machine.
1. Enums and Domain Classes
public enum Coin {
NICKEL(5), DIME(10), QUARTER(25), DOLLAR(100);
private final int value;
Coin(int value) { this.value = value; }
public int getValue() { return value; }
}
public class Product {
private final String name;
private final int price;
public Product(String name, int price) {
this.name = name;
this.price = price;
}
public String getName() { return name; }
public int getPrice() { return price; }
}
2. State Interface and Concrete Implementations
import java.util.*;
public interface VendingState {
void insertCoin(VendingMachine machine, Coin coin);
void selectProduct(VendingMachine machine, String slotId);
void dispense(VendingMachine machine);
void refund(VendingMachine machine);
}
// 1. Idle State
public class IdleState implements VendingState {
@Override
public void insertCoin(VendingMachine machine, Coin coin) {
machine.addBalance(coin.getValue());
System.out.println("Coin inserted: " + coin.name() + ". Balance: " + machine.getBalance());
machine.setCurrentState(machine.getHasMoneyState());
}
@Override
public void selectProduct(VendingMachine machine, String slotId) {
System.out.println("Insert coin first.");
}
@Override
public void dispense(VendingMachine machine) {
System.out.println("Insert coin and select product first.");
}
@Override
public void refund(VendingMachine machine) {
System.out.println("No balance to refund.");
}
}
// 2. Has Money State
public class HasMoneyState implements VendingState {
@Override
public void insertCoin(VendingMachine machine, Coin coin) {
machine.addBalance(coin.getValue());
System.out.println("Additional coin inserted. Balance: " + machine.getBalance());
}
@Override
public void selectProduct(VendingMachine machine, String slotId) {
Product product = machine.getInventory().getProduct(slotId);
if (product == null) {
System.out.println("Invalid slot.");
return;
}
if (!machine.getInventory().isAvailable(slotId)) {
System.out.println("Product out of stock.");
machine.setCurrentState(machine.getOutOfStockState());
return;
}
if (machine.getBalance() < product.getPrice()) {
System.out.println("Insufficient balance. Price: " + product.getPrice());
return;
}
machine.setSelectedSlot(slotId);
machine.setCurrentState(machine.getDispensingState());
machine.dispense();
}
@Override
public void dispense(VendingMachine machine) {
System.out.println("Select a product first.");
}
@Override
public void refund(VendingMachine machine) {
int refundAmount = machine.getBalance();
machine.setBalance(0);
System.out.println("Refunding balance: " + refundAmount);
machine.setCurrentState(machine.getIdleState());
}
}
// 3. Dispensing State
public class DispensingState implements VendingState {
@Override
public void insertCoin(VendingMachine machine, Coin coin) {
System.out.println("Wait, currently dispensing.");
}
@Override
public void selectProduct(VendingMachine machine, String slotId) {
System.out.println("Wait, currently dispensing.");
}
@Override
public void dispense(VendingMachine machine) {
String slotId = machine.getSelectedSlot();
Product product = machine.getInventory().getProduct(slotId);
machine.getInventory().decrementStock(slotId);
machine.deductBalance(product.getPrice());
System.out.println("Dispensing product: " + product.getName());
// Return change if any
int change = machine.getBalance();
if (change > 0) {
System.out.println("Returning change: " + change);
machine.setBalance(0);
}
machine.setSelectedSlot(null);
if (machine.getInventory().isEmpty()) {
machine.setCurrentState(machine.getOutOfStockState());
} else {
machine.setCurrentState(machine.getIdleState());
}
}
@Override
public void refund(VendingMachine machine) {
System.out.println("Cannot refund during dispense.");
}
}
// 4. Out Of Stock State
public class OutOfStockState implements VendingState {
@Override
public void insertCoin(VendingMachine machine, Coin coin) {
System.out.println("Machine is out of stock. Cannot insert money.");
}
@Override
public void selectProduct(VendingMachine machine, String slotId) {
System.out.println("Machine is out of stock.");
}
@Override
public void dispense(VendingMachine machine) {
System.out.println("Machine is out of stock.");
}
@Override
public void refund(VendingMachine machine) {
if (machine.getBalance() > 0) {
System.out.println("Refunding balance: " + machine.getBalance());
machine.setBalance(0);
}
machine.setCurrentState(machine.getIdleState());
}
}
3. Inventory and Vending Machine Context
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public class Inventory {
private final Map<String, Product> products = new ConcurrentHashMap<>();
private final Map<String, AtomicInteger> stock = new ConcurrentHashMap<>();
public void addProduct(String slotId, Product product, int initialStock) {
products.put(slotId, product);
stock.put(slotId, new AtomicInteger(initialStock));
}
public Product getProduct(String slotId) { return products.get(slotId); }
public boolean isAvailable(String slotId) {
AtomicInteger count = stock.get(slotId);
return count != null && count.get() > 0;
}
public void decrementStock(String slotId) {
AtomicInteger count = stock.get(slotId);
if (count != null) {
count.decrementAndGet();
}
}
public boolean isEmpty() {
return stock.values().stream().allMatch(count -> count.get() == 0);
}
}
public class VendingMachine {
private final VendingState idleState = new IdleState();
private final VendingState hasMoneyState = new HasMoneyState();
private final VendingState dispensingState = new DispensingState();
private final VendingState outOfStockState = new OutOfStockState();
private VendingState currentState = idleState;
private final Inventory inventory = new Inventory();
private int balance = 0;
private String selectedSlot = null;
public VendingMachine() {
// Init default inventory
inventory.addProduct("A1", new Product("Soda", 150), 5);
inventory.addProduct("B1", new Product("Chips", 100), 10);
}
public synchronized void insertCoin(Coin coin) {
currentState.insertCoin(this, coin);
}
public synchronized void selectProduct(String slotId) {
currentState.selectProduct(this, slotId);
}
public synchronized void dispense() {
currentState.dispense(this);
}
public synchronized void refund() {
currentState.refund(this);
}
// Setters & Getters
public void setCurrentState(VendingState state) { this.currentState = state; }
public int getBalance() { return balance; }
public void setBalance(int balance) { this.balance = balance; }
public void addBalance(int amount) { this.balance += amount; }
public void deductBalance(int amount) { this.balance -= amount; }
public Inventory getInventory() { return inventory; }
public String getSelectedSlot() { return selectedSlot; }
public void setSelectedSlot(String slotId) { this.selectedSlot = slotId; }
public VendingState getIdleState() { return idleState; }
public VendingState getHasMoneyState() { return hasMoneyState; }
public VendingState getDispensingState() { return dispensingState; }
public VendingState getOutOfStockState() { return outOfStockState; }
}
📚 Lessons Learned: LLD Best Practices
When designing state-driven LLD systems:
- Keep Context Synchronized: Always declare context methods that alter mutable variables (like
insertCoin,selectProduct) assynchronizedto ensure thread-safety in multi-threaded container environments. - Inject Context: Pass the context reference (
VendingMachine) to the state methods, allowing states to be stateless singletons. - Decouple Controllers from States: Controllers should only invoke entry points on the main context class, preserving encapsulation of internal state transitions.
📌 Summary: The State Pattern Cheatsheet
- State Pattern: Encapsulates state-specific logic in dedicated classes, avoiding nested if-else structures.
- OOP Abstraction: Hides transition details completely behind interface contracts.
- Stateless States: Pass context as method parameters to allow sharing state instances safely.
- Thread Safety: Use
synchronizedmethods on the context wrapper to prevent race conditions during concurrent payments. - OCP Compliant: Introduce new product types or billing modes without editing existing classes.
AI-generated article quiz
Test your understanding
Ready to test what you just learned?
Generate four focused questions from this article. Answers include immediate explanations.
Guided series path
Low-Level Design Guide
Reader feedback
Was this article useful?
Rate it if it helped, then continue with the next deep dive when you are ready.
Sign in to save your rating.
Article metadata