Skip to main content

Vending Machine

The textbook demo of the State pattern, and for good reason: a vending machine really does mean something different by "insert coin" depending on whether it's waiting for money, already holding some, or mid-dispense. Model that literally - one class per state - and the machine practically writes itself.

Requirements

Functional

  • A customer inserts coins, selects a product by code, and either gets the product plus any change, or gets a rejection (insufficient funds, sold-out slot) with coins returned.
  • Inserting more coins after already having enough should still work - the machine keeps a running balance, not a single coin.
  • Selecting a product it doesn't have in stock returns the coins already inserted.

Non-functional

  • The set of states (and the rules for what's legal in each) should be easy to extend - a "maintenance" state that rejects everything is a plausible future ask.
  • Coin handling and product/inventory management are separate concerns; neither state class should need to know how the other is implemented.

Design

VendingMachine is the context; it holds a VendingState and forwards every customer action - insertCoin, selectProduct, dispense - to whichever state object it currently holds. Every state implements the same three methods, and most of them implement two of the three as "that's not legal right now."

CustomerVendingMachineVendingStateInventoryinsertCoin(100)1insertCoin(machine, 100)2selectProduct("B2")3selectProduct(machine, "B2")4checkStock("B2")5transition to DispensingState6dispense()7
  1. 1The machine forwards this straight to its current state - right now, IdleState.
  2. 2IdleState records the balance and swaps the machine into HasMoneyState.
  3. 3The customer picks a slot.
  4. 4HasMoneyState is the only state where a selection can possibly succeed.
  5. 5Stock and pricing are Inventory’s job, not the state’s - it just asks.
  6. 6Enough balance and stock: the state swaps itself for DispensingState.
  7. 7DispensingState releases the product, returns change, and resets the machine to IdleState.

Class diagram

«interface»VendingState+ insertCoin(m, cents)+ selectProduct(m, code)+ dispense(m)VendingMachine- state: VendingState- balance: int- inventory: Inventory+ insertCoin(cents)+ selectProduct(code)+ dispense()IdleState+ insertCoin(m, cents)+ selectProduct(m, code)+ dispense(m)HasMoneyState+ insertCoin(m, cents)+ selectProduct(m, code)+ dispense(m)DispensingState+ insertCoin(m, cents)+ selectProduct(m, code)+ dispense(m)Inventory- slots: Map<string, Slot>+ checkStock(code): bool+ priceOf(code): int+ takeOne(code)
implementsuses
VendingMachine forwards every action to its current VendingState; Inventory is a separate concern the states delegate to.

Code

import java.util.*;
 
class Inventory {
private final Map<String, Integer> prices = new HashMap<>();
private final Map<String, Integer> stock = new HashMap<>();
 
void addSlot(String code, int priceCents, int quantity) {
prices.put(code, priceCents);
stock.put(code, quantity);
}
 
boolean inStock(String code) {
return stock.getOrDefault(code, 0) > 0;
}
 
int priceOf(String code) {
return prices.get(code);
}
 
void takeOne(String code) {
stock.put(code, stock.get(code) - 1);
}
}
 
interface VendingState {
void insertCoin(VendingMachine machine, int cents);
void selectProduct(VendingMachine machine, String code);
void dispense(VendingMachine machine);
}
 
class IdleState implements VendingState {
public void insertCoin(VendingMachine machine, int cents) {
machine.addBalance(cents);
machine.setState(new HasMoneyState());
}
 
public void selectProduct(VendingMachine machine, String code) {
throw new IllegalStateException("Insert coins first");
}
 
public void dispense(VendingMachine machine) {
throw new IllegalStateException("Insert coins first");
}
}
 
class HasMoneyState implements VendingState {
public void insertCoin(VendingMachine machine, int cents) {
machine.addBalance(cents);
}
 
public void selectProduct(VendingMachine machine, String code) {
Inventory inventory = machine.getInventory();
if (!inventory.inStock(code)) {
throw new IllegalStateException("Sold out: " + code);
}
if (machine.getBalance() < inventory.priceOf(code)) {
throw new IllegalStateException("Insufficient funds");
}
machine.setSelectedCode(code);
machine.setState(new DispensingState());
}
 
public void dispense(VendingMachine machine) {
throw new IllegalStateException("Select a product first");
}
}
 
class DispensingState implements VendingState {
public void insertCoin(VendingMachine machine, int cents) {
throw new IllegalStateException("Already dispensing");
}
 
public void selectProduct(VendingMachine machine, String code) {
throw new IllegalStateException("Already dispensing");
}
 
public void dispense(VendingMachine machine) {
String code = machine.getSelectedCode();
Inventory inventory = machine.getInventory();
int change = machine.getBalance() - inventory.priceOf(code);
inventory.takeOne(code);
System.out.println("Dispensing " + code + ", change: " + change);
machine.reset();
machine.setState(new IdleState());
}
}
 
class VendingMachine {
private VendingState state = new IdleState();
private int balance = 0;
private String selectedCode;
private final Inventory inventory;
 
VendingMachine(Inventory inventory) {
this.inventory = inventory;
}
 
void insertCoin(int cents) {
state.insertCoin(this, cents);
}
 
void selectProduct(String code) {
state.selectProduct(this, code);
}
 
void dispense() {
state.dispense(this);
}
 
void addBalance(int cents) {
balance += cents;
}
 
void reset() {
balance = 0;
selectedCode = null;
}
 
void setState(VendingState state) {
this.state = state;
}
 
void setSelectedCode(String code) {
this.selectedCode = code;
}
 
String getSelectedCode() {
return selectedCode;
}
 
int getBalance() {
return balance;
}
 
Inventory getInventory() {
return inventory;
}
}

Design decisions

  • State pattern earns its keep here. Unlike the ATM (four transitions, little per-state behavior), "what does insertCoin mean" genuinely differs in each of these three states - accumulate a new balance, add to an existing one, or reject outright because a dispense is already underway. That's exactly the shape the pattern exists for.
  • Inventory is not a VendingState responsibility. Stock levels and prices live in their own class that any state can query, so "is this slot sold out" is answered in one place instead of duplicated across HasMoneyState and wherever else might need it.
  • Change calculation lives in DispensingState, not VendingMachine. The moment of dispensing is also the moment change is owed, so keeping that math next to the state that triggers it avoids a VendingMachine.calculateChange() method nothing else calls.
  • What's missing for a real system: exact-change tracking (a real machine refuses a sale it can't make change for, which means modeling the coin inventory the machine is holding, not just the customer's balance) and a coin-jam / sensor-fault path, which would be a good candidate for the maintenance state mentioned above.
0%0 of 122 pages studied