Skip to main content

Coffee Vending Machine

Design the software behind a coffee machine: pick a drink, the machine checks it has enough of every ingredient, brews, and updates its stock. The interesting part isn't the brewing - it's that "idle", "selecting a drink", and "dispensing" behave differently for the exact same button presses, and that difference has to live somewhere sane.

Requirements

Functional

  • The machine offers a fixed menu of Recipes (Espresso, Latte, Cappuccino, ...), each needing specific quantities of Ingredients (water, milk, coffee beans, sugar).
  • A customer selects a recipe; the machine checks every required ingredient has enough stock before starting.
  • If stock is insufficient, the machine reports which ingredient is short and returns to idle - it never starts brewing halfway.
  • While dispensing, the machine ignores new selections until the current drink finishes.
  • Ingredient levels can be topped up by a technician at any time, including mid-brew for a different slot.

Non-functional

  • Adding a new drink to the menu is a new Recipe value, never a new if branch in the machine's control flow.
  • The set of valid actions ("press select", "press cancel") has to differ by mode without a wall of mode-checking conditionals in one method.

Design

VendingMachine holds one MachineState at a time and forwards every button press to it; the state decides whether the press does anything and what state comes next. This is the same State pattern shape as the traffic light in Traffic Control - a next()-shaped transition - but here the trigger is a customer action instead of a timer, and a transition can be rejected (pressing "select" while Dispensing does nothing) rather than always succeeding, and IdleState itself is the one that checks stock before ever swapping in DispensingState.

CustomerVendingMachineMachineStateRecipeselectRecipe(latte)1selectRecipe(latte)2getRequiredIngredients()3setState(dispensingState)4finishBrew()5
  1. 1The machine never decides what a button press means - it just forwards the press to whatever state is current.
  2. 2IdleState handles a selection; DispensingState would ignore this same call entirely.
  3. 3The state asks the recipe what it needs, then checks that against the machine’s stock.
  4. 4Enough stock: the state swaps the machine into DispensingState and starts the brew.
  5. 5When brewing completes, DispensingState deducts stock and swaps the machine back to idle.

Recipe and Ingredient are pure data; all the interesting behavior - "can I afford this?", "what do I do when selected?" - is on the state objects, never on the machine.

Class diagram

«interface»MachineState+ selectRecipe(r)+ cancel()+ finishBrew()VendingMachine- stock: Map<Ingredient, int>- currentState: MachineState+ selectRecipe(r): void+ cancel(): void+ setState(s)IdleState+ selectRecipe(r)DispensingState+ finishBrew()Recipe- name: string- requiredIngredients: Map<Ingredient, int>
implementsuses
VendingMachine forwards every action to its current MachineState; states check stock and swap each other in.

Code

import java.util.*;
 
enum Ingredient { WATER, MILK, COFFEE_BEANS, SUGAR }
 
class Recipe {
final String name;
final Map<Ingredient, Integer> requiredIngredients;
 
Recipe(String name, Map<Ingredient, Integer> requiredIngredients) {
this.name = name;
this.requiredIngredients = requiredIngredients;
}
}
 
interface MachineState {
default void selectRecipe(Recipe r) {}
default void cancel() {}
default void finishBrew() {}
}
 
class VendingMachine {
private final Map<Ingredient, Integer> stock;
private MachineState currentState;
private Recipe pendingRecipe;
 
VendingMachine(Map<Ingredient, Integer> stock) {
this.stock = stock;
this.currentState = new IdleState(this);
}
 
void setState(MachineState state) {
this.currentState = state;
}
 
boolean hasEnoughStock(Recipe recipe) {
for (var entry : recipe.requiredIngredients.entrySet()) {
if (stock.getOrDefault(entry.getKey(), 0) < entry.getValue()) return false;
}
return true;
}
 
void deductStock(Recipe recipe) {
for (var entry : recipe.requiredIngredients.entrySet()) {
stock.merge(entry.getKey(), -entry.getValue(), Integer::sum);
}
}
 
void restock(Ingredient ingredient, int amount) {
stock.merge(ingredient, amount, Integer::sum);
}
 
void setPendingRecipe(Recipe r) { this.pendingRecipe = r; }
Recipe getPendingRecipe() { return pendingRecipe; }
 
void selectRecipe(Recipe r) { currentState.selectRecipe(r); }
void cancel() { currentState.cancel(); }
void finishBrew() { currentState.finishBrew(); }
}
 
class IdleState implements MachineState {
private final VendingMachine machine;
 
IdleState(VendingMachine machine) { this.machine = machine; }
 
public void selectRecipe(Recipe r) {
if (!machine.hasEnoughStock(r)) {
System.out.println("Not enough ingredients for " + r.name);
return;
}
machine.setPendingRecipe(r);
machine.setState(new DispensingState(machine));
}
}
 
class DispensingState implements MachineState {
private final VendingMachine machine;
 
DispensingState(VendingMachine machine) { this.machine = machine; }
 
public void finishBrew() {
Recipe r = machine.getPendingRecipe();
machine.deductStock(r);
System.out.println("Dispensed " + r.name);
machine.setState(new IdleState(machine));
}
}

Design decisions

  • MachineState decides both "does this button do anything right now" and "what's next". Cramming that into VendingMachine means every method starts with a mode check (if (mode == DISPENSING) return;). Pushing it onto the state means DispensingState.selectRecipe() can simply be a no-op override - the "ignored while busy" rule is enforced by which method exists, not by a guard clause repeated everywhere.
  • Stock is checked once, at selection, not re-checked mid-brew. A technician topping up a different slot mid-dispense must never affect a brew already in progress, so IdleState snapshots "can I afford this recipe" before handing off to DispensingState, which only ever deducts - it never re-validates.
  • Recipe stores ingredient requirements, VendingMachine stores ingredient stock - two different maps, never merged. A recipe is fixed menu data; stock changes every time a drink is made or a technician refills. Keeping them separate means restocking never touches the menu and adding a drink never touches inventory code.
  • What's missing for a real system: concurrent selections (two people at a touchscreen and a mobile app at once) need the state transition itself to be atomic, not just the state check, and partial-refund handling (customer cancels after paying but before brewing starts) needs a PaymentState step this example skips to keep the state machine to three colors instead of five.
0%0 of 122 pages studied