8. Coffee Vending Machine Beginner → Intermediate

A. Realistic Interview Prompt

"Design a coffee vending machine. It offers several beverages, each requiring specific quantities of ingredients (water, milk, coffee, sugar). A user selects a beverage, pays, and the machine dispenses it — or rejects the request if ingredients are insufficient."

B. Difficulty

Beginner → Intermediate. Deliberately the smallest, most contained problem in this set — often used as a warm-up or as the "textbook State pattern" round, precisely because the machine's behavior really does change shape by state in a way that's hard to fake with a simple boolean flag.

C. What This Problem Tests

D. Requirements

Functional

  1. Offer several beverages, each with a fixed recipe (ingredient → required quantity)
  2. Let a user select a beverage and pay via cash or card
  3. Dispense the beverage if and only if all required ingredients are sufficiently stocked
  4. Reject the selection (and refund, if paid) if ingredients are insufficient
  5. Allow a technician to refill ingredients

Non-Functional

E. Clarifying Questions

#QuestionWhy It MattersDesign Decision Affected
1Does the machine have one brewing unit, or multiple slots that can brew concurrently sharing one ingredient hopper?Determines whether concurrency actually matters here or is a stretch extensionScope of W (Concurrency)
2What payment methods are required — cash, card, mobile wallet?Determines the PaymentStrategy setPayment interface scope
3What happens on a mid-dispense failure (e.g. physically jams)?Determines whether reserved ingredients need to be "refunded" back to inventoryState machine's failure transition
4Should low-inventory trigger an alert to a technician?Determines whether Observer is in scopePresence of a restock-alert listener
5Can a user cancel mid-selection before paying?Adds a transition edge to the state machineCoffeeMachineState transition table

F. Assumptions

G. Actors

ActorResponsibility
UserSelects a beverage, pays, receives it or a refund
TechnicianRefills ingredients, services the machine
CoffeeMachineEnforces state transitions, checks stock, coordinates dispensing

H. Core Domain Entities

ClassResponsibilityKey AttributesKey MethodsDoes NOT Handle
IngredientOne stocked ingredientname, quantity, capacityreserve(qty), release(qty), refill(qty)Recipes or pricing
RecipeData: what a beverage needsMap<ingredient, requiredQty>, price—Any computation — pure data, same role as Splitwise's Split
BeverageA sellable itemname, recipe—Brewing logic
CoffeeMachineState (interface)What's legal to do right now—selectBeverage(), insertPayment(), dispense(), cancel()The actual ingredient math (delegates to the machine/context)
PaymentStrategy (interface)One payment method—charge(amount), refund(amount)Selection logic, dispensing
CoffeeMachine (context/facade)Owns current state, inventory, recipes; drives the whole flowcurrentState, ingredients, beveragesselect(), pay(), dispense()State-specific legality checks (delegated to the current state object)

I. Class Relationships

J. Class Diagram

delegates

Ingredient

name

quantity

capacity

+reserve(qty)

+release(qty)

+refill(qty)

Recipe

requirements

price

Beverage

name

recipe

«interface»

CoffeeMachineState

+selectBeverage()

+insertPayment()

+dispense()

+cancel()

«interface»

PaymentStrategy

+charge(amount)

+refund(amount)

CoffeeMachine

currentState

+select()

+pay()

+dispense()

IdleState

SelectingState

DispensingState

OutOfStockState

CashPayment

CardPayment

View Mermaid source
classDiagram
  class Ingredient {
    name
    quantity
    capacity
    +reserve(qty)
    +release(qty)
    +refill(qty)
  }
  class Recipe {
    requirements
    price
  }
  class Beverage {
    name
    recipe
  }
  class CoffeeMachineState {
    <<interface>>
    +selectBeverage()
    +insertPayment()
    +dispense()
    +cancel()
  }
  class PaymentStrategy {
    <<interface>>
    +charge(amount)
    +refund(amount)
  }
  class CoffeeMachine {
    currentState
    +select()
    +pay()
    +dispense()
  }
  CoffeeMachine --> CoffeeMachineState : delegates
  CoffeeMachineState <|.. IdleState
  CoffeeMachineState <|.. SelectingState
  CoffeeMachineState <|.. DispensingState
  CoffeeMachineState <|.. OutOfStockState
  Beverage --> Recipe
  Recipe --> Ingredient
  CoffeeMachine --> PaymentStrategy
  PaymentStrategy <|.. CashPayment
  PaymentStrategy <|.. CardPayment

K. Responsibility Mapping

ClassResponsibilityDoes NOT Handle
CoffeeMachineStateWhat's legal right nowIngredient math, payment mechanics
IngredientIts own quantity, atomicallyRecipes, pricing
RecipePure data: what's neededAny computation
PaymentStrategyCharge/refund mechanics for one methodDeciding when to charge (that's the state machine's job)
CoffeeMachineOrchestration and state delegationEvery specific check (delegated to state/ingredient/payment objects)

L. SOLID Analysis

PrincipleHow It's AppliedNaive Violation
SRPIngredient only tracks quantity; PaymentStrategy only handles money; state objects only enforce legalityOne CoffeeMachine class with a status string and giant if/else blocks checking it everywhere
OCPNew beverage = new Recipe data, zero code changes; new payment method = new PaymentStrategyif (beverage == "LATTE") ... else if (beverage == "MOCHA") ... inline brewing logic per drink
LSPEvery CoffeeMachineState implements all four actions, even if most just throw/reject ("can't dispense while Idle") — a defined, expected response, not an inconsistent crashA state that silently no-ops on an illegal action instead of rejecting it clearly, masking bugs in the calling code
ISPSmall, focused PaymentStrategy and CoffeeMachineState interfacesA fat interface also requiring printReceipt(), sendEmail(), logAnalytics()
DIPCoffeeMachine depends on CoffeeMachineState and PaymentStrategy as interfacesCoffeeMachine directly checking if (cash) ... else ... inline instead of delegating to a payment object

M. Design Patterns

PatternProblem It SolvesWhere It AppearsWhen NOT to Use
StateLegal actions genuinely differ by machine state, not just cosmeticallyCoffeeMachineState hierarchy, delegated to by CoffeeMachineSkip only if the machine truly has one state ever (unrealistic for this prompt)
StrategyPayment method is a mutually exclusive alternativePaymentStrategySkip if only one payment method will ever exist
Observer (optional)Low-inventory alerts to a technician shouldn't couple the machine to a specific alerting mechanismAn onLowInventory hook fired when an ingredient crosses a threshold — could plug directly into Problem 7's NotificationServiceSkip if refill alerts are out of scope for the round

Explicitly avoided: Template Method for per-beverage brewing steps — see I for why the variation here is data (a Recipe), not behavior, making a beverage-subclass hierarchy pure overhead.

N. What Is Likely to Change?

BehaviorCurrent DesignAnticipated ChangeProtecting Abstraction
Beverage menuFixed set of recipesSeasonal/customizable recipes (extra shot, less sugar)Recipe as data the machine reads, not hardcoded per-beverage logic
PaymentCash, cardMobile wallet, loyalty pointsPaymentStrategy interface
Machine topologySingle brewing unitMultiple slots sharing one hopperIngredient.reserve() designed atomic from day one (see W)
AlertingNoneLow-inventory notifications to a technician or ops dashboardObserver hook, not a hardcoded call

O. Design Evolution

What stays stable: CoffeeMachine.select()/pay()/dispense() signatures. New beverages are pure data additions; new payment methods and alerting are additive via existing interfaces.

P. API / Interface Design

interface CoffeeMachineState {
  void selectBeverage(CoffeeMachine ctx, Beverage b);
  void insertPayment(CoffeeMachine ctx, PaymentStrategy method, Money amount);
  void dispense(CoffeeMachine ctx);
  void cancel(CoffeeMachine ctx);
}

class CoffeeMachine {
  void select(Beverage b);      // Idle -> Selecting, or rejects if not Idle
  void pay(PaymentStrategy method, Money amount);  // Selecting -> Dispensing
  void dispenseNow();           // Dispensing -> Idle (or -> OutOfStock)
  void cancel();                // Selecting -> Idle, refunds if already paid
  void refill(Ingredient ing, int qty);  // technician action, any state
}

Ownership/validation notes: dispenseNow() reserves all required ingredients atomically before physically dispensing anything — if reservation fails partway (one ingredient insufficient), nothing is reserved and the machine transitions to OutOfStockState with a refund, never a partial drink.

Q. Pseudocode — Critical Logic

CoffeeMachine.dispenseNow():
  recipe = this.selectedBeverage.recipe
  reserved = []
  for (ingredient, qty) in recipe.requirements:
    if not ingredient.tryReserve(qty):        // atomic check-and-decrement
      for r in reserved: r.ingredient.release(r.qty)   // roll back partial reservation
      this.paymentMethod.refund(recipe.price)
      this.state = OutOfStockState
      return
    reserved.add((ingredient, qty))
  // all ingredients reserved successfully — safe to physically dispense
  dispenser.pour(recipe)
  this.state = IdleState

R. End-to-End Use Case

User selects Latte (Idle -> Selecting)
  │
  ▼
CoffeeMachine.select(Latte)
  │
  ▼
User pays $4 via card (Selecting -> Dispensing)
  │
  ▼
CoffeeMachine.pay(CardPayment, $4) → CardPayment.charge($4) succeeds
  │
  ▼
CoffeeMachine.dispenseNow()
  │
  ▼
Ingredient.tryReserve(milk, 200ml) ✓
Ingredient.tryReserve(coffee, 18g) ✓
Ingredient.tryReserve(water, 30ml) ✓
  │
  ▼
Dispenser.pour(recipe) → Latte dispensed → state: Dispensing -> Idle

S. Data Structures

StructureUsed ForWhyAlternative & Trade-off
Map<String, Ingredient>Inventory lookup by nameO(1) access when checking/reserving a recipe's requirementsA list with linear scan is fine at this tiny scale but needlessly slower
Atomic integer (or a mutex-guarded counter) per Ingredientreserve()/release()Guarantees no two concurrent dispense attempts both succeed against the same insufficient stockA single global lock around the whole machine is simpler but serializes unrelated ingredients unnecessarily on a multi-slot machine

T. Complexity Analysis

OperationTimeSpace
dispenseNow()O(ingredients in recipe)O(ingredients in recipe) for the reservation rollback list
refill()O(1)—

U. Testing Strategy

V. Interviewer Follow-Ups

Follow-UpTestingDesign ChangeClasses ChangedPattern/Trade-off
"Support multiple brewing slots sharing one milk/coffee hopper."Recognizing the check-then-act race at small scaleAtomic Ingredient.reserve(), same shape as Parking Lot's spot occupationIngredientSame lesson, tiny domain — good for showing pattern transfer, not just memorized to one problem
"Add a loyalty-points payment option."OCP under a real new requirementNew LoyaltyPointsPayment implementing PaymentStrategyNew class onlyValidates the Strategy investment
"Alert a technician when milk drops below 10%."Decoupling alerting from the core flowIngredient fires a threshold event an Observer listens forNew Observer hook, no changes to dispensing logicCould route directly into Problem 7's NotificationService — a nice concrete cross-reference
"Why not make each beverage its own subclass with its own brew() override?"Testing whether the candidate over-applies Template MethodN/A — the answer is to explain why not (see I)N/AA strong candidate defends the data-driven Recipe approach without being told it's wrong first

W. Concurrency

Shared mutable state: each Ingredient's quantity, if the machine has multiple brewing slots sharing one hopper.

X. Persistence

Minimal for this problem: ingredient inventory levels need to survive a machine restart/power cycle (a simple local persisted counter, updated on every reserve/refill), and a sales/dispense log is useful for a technician's restocking schedule but isn't a hard requirement unless the round asks for reporting.

Y. Scaling Boundary

LayerWhat Belongs Here
LLDState machine, Ingredient/Recipe/PaymentStrategy classes, atomic reservation
HLDFleet-wide monitoring across many physical machines, remote restock scheduling, centralized sales analytics
Distributed SystemsEssentially not applicable — this is inherently a single-device problem; a strong candidate notes this rather than inventing distributed concerns that don't exist here

Z. Failure Scenarios

AA. Common Bad Designs

AB. Design Trade-Offs

DecisionOption AOption BTrade-off
Beverage variation modelingData-driven Recipe (chosen)Beverage-subclass-per-drink with Template MethodData-driven has zero behavioral variation to justify a hierarchy; subclassing would be pure ceremony here specifically because nothing actually overrides a step
Payment-before-check vs. check-before-paymentCharge first, refund on failureCheck ingredients first, then chargeCheck-first avoids ever needing a refund path at all; charge-first is sometimes required if the payment terminal is a separate physical step that can't be "un-started"
Ingredient concurrencyAtomic per-ingredient reserveGlobal machine lockPer-ingredient is only worth it on multi-slot machines; a single-slot machine gets no benefit from either and shouldn't bother

AC. Interviewer Evaluation

AD. Interview Simulation

Interviewer: Design a coffee vending machine.
Candidate: The machine's behavior clearly depends on where it is in a flow — idle, selecting, dispensing — so I'd model that as a State pattern rather than a status flag, since the legal actions genuinely differ by state.
Interviewer: Would you make each beverage its own class, since they each brew differently?
Candidate: Actually no — every beverage follows the exact same steps: check ingredients, reserve them, combine, dispense. Only the quantities differ, and that's data, not behavior. I'd keep Recipe as a data object and use one shared brew() method rather than a beverage-subclass hierarchy with nothing to actually override.
Interviewer: What if the machine has two brewing heads sharing one milk container?
Candidate: Then reserving milk needs to be atomic — check-and-decrement in one step — or two concurrent brews could both see "enough milk" and overdraw it. Same race condition as the parking-spot problem, just a much smaller blast radius.
Interviewer: What happens if reservation succeeds for milk and coffee but fails for sugar?
Candidate: I'd roll back the milk and coffee reservations and refund the payment — an all-or-nothing reservation, so the machine never ends up in a state where it's charged the user for a drink it can't actually make.

AE. Online Solution Comparison

SourceApproachStrengthWeaknessWorth Learning
ashishps1/awesome-low-level-design (GitHub)Coffee/Ingredient/Payment/CoffeeMachine classes; CoffeeMachine as Singleton; synchronized methods on Ingredient for quantity updates; simulates concurrent requests via ExecutorServiceConcrete, correct instinct on synchronizing ingredient updates — validates this document's atomic-reservation approachNo explicit State pattern — behavior isn't shown as delegated to state objects; Singleton for the machine itself is reasonable for a single physical device but isn't argued for the way this document argues it for Problem 6's logger registryThe synchronized-ingredient-update instinct, directly reflected in this document's Ingredient.reserve()
Animesh Gaitonde — Vending Machine design: A State design pattern approach (Medium)Explicit State-pattern walkthrough with Idle/Selection/HasMoney/Dispensing states for a general vending machineThe clearest published justification for why State beats a status flag here — directly validates this document's headline pattern choiceGeneral vending machine, not coffee-specific — no ingredient-recipe layer to reconcile with the state machineThe state-transition diagram approach itself, adopted directly as this document's CoffeeMachineState hierarchy

AF. Interview Cheat Sheet

ProblemCoffee Vending Machine
DifficultyBeginner → Intermediate
Core requirementsRecipes, ingredient-sufficiency check, payment, dispense-or-refuse, refill
Key clarificationsSingle or multi-slot? Payment methods? Mid-dispense failure handling? Low-inventory alerts? Mid-selection cancellation?
Core entitiesIngredient, Recipe, Beverage, CoffeeMachineState, PaymentStrategy, CoffeeMachine
Key relationshipsState drives legality; Recipe is pure data (Template Method explicitly rejected); Payment is a Strategy
Key patternsState (headline), Strategy (payment), optional Observer (low-inventory alerts)
Most important decisionAtomic, all-or-nothing ingredient reservation with rollback on partial failure
Likely-changing behaviorMenu/recipes, payment methods, machine topology (single vs. multi-slot), alerting
Concurrency concernShared-hopper check-then-act race across multiple brewing slots — only relevant if multi-slot
Top follow-upsMulti-slot sharing, new payment method, low-inventory alerting, defending the data-driven Recipe over subclassing
Common mistakesStatus-flag instead of State; check-then-decrement race; beverage-subclass-per-drink over-engineering; charging before confirming ingredients
What NOT to over-engineerDon't add distributed-systems concerns to a single physical device, and don't add Template Method where there's no actual behavioral variation

AG. Full Implementation

A complete, compilable reference implementation of every class in the diagram above (composition, inheritance, interface realization, and dependency-injected strategies all preserved per section I), with a runnable demo exercising the primary flow — in Java, C++, JavaScript, and TypeScript.

// Coffee Vending Machine -- full LLD implementation with demo.
// Single-file Java translation of the Mermaid class diagram in section J.
import java.util.*;

public class p8 {

    // ---------- Ingredient ----------
    /** One stocked ingredient; owns its own quantity so reserve/release/refill stay in one place. */
    static final class Ingredient {
        final String name;
        int quantity;
        final int capacity;

        Ingredient(String name, int quantity, int capacity) {
            this.name = name; this.quantity = quantity; this.capacity = capacity;
        }

        /** Atomic check-and-decrement: never lets quantity go negative. */
        boolean reserve(int qty) {
            if (quantity < qty) return false;
            quantity -= qty;
            return true;
        }
        void release(int qty) { quantity = Math.min(capacity, quantity + qty); }
        void refill(int qty) { quantity = Math.min(capacity, quantity + qty); }
        public String toString() { return name + "(" + quantity + "/" + capacity + ")"; }
    }

    // ---------- Recipe ----------
    /** Pure data: what a beverage needs and how much it costs. No computation. */
    static final class Recipe {
        final Map<Ingredient, Integer> requirements;
        final int priceCents;
        Recipe(Map<Ingredient, Integer> requirements, int priceCents) {
            this.requirements = requirements; this.priceCents = priceCents;
        }
    }

    // ---------- Beverage ----------
    /** A sellable item. Composes its Recipe -- the recipe has no meaning outside the beverage. */
    static final class Beverage {
        final String name;
        final Recipe recipe;
        Beverage(String name, Recipe recipe) { this.name = name; this.recipe = recipe; }
        public String toString() { return name; }
    }

    // ---------- PaymentStrategy (Strategy interface) ----------
    interface PaymentStrategy {
        void charge(int amountCents);
        void refund(int amountCents);
    }

    static final class CashPayment implements PaymentStrategy {
        int inserted = 0;
        public void charge(int amountCents) {
            if (inserted < amountCents) throw new IllegalStateException("Insufficient cash inserted");
            inserted -= amountCents;
            System.out.println("  [CASH] charged " + amountCents + "c, change=" + inserted + "c");
        }
        public void refund(int amountCents) {
            inserted += amountCents;
            System.out.println("  [CASH] refunded " + amountCents + "c");
        }
        void insert(int cents) { inserted += cents; }
    }

    static final class CardPayment implements PaymentStrategy {
        public void charge(int amountCents) { System.out.println("  [CARD] charged " + amountCents + "c"); }
        public void refund(int amountCents) { System.out.println("  [CARD] refunded " + amountCents + "c"); }
    }

    // ---------- CoffeeMachineState (State interface) ----------
    interface CoffeeMachineState {
        void selectBeverage(CoffeeMachine ctx, Beverage b);
        void insertPayment(CoffeeMachine ctx, PaymentStrategy method, int amountCents);
        void dispense(CoffeeMachine ctx);
        void cancel(CoffeeMachine ctx);
    }

    static final class IdleState implements CoffeeMachineState {
        public void selectBeverage(CoffeeMachine ctx, Beverage b) {
            ctx.selectedBeverage = b;
            ctx.setState(ctx.selectingState);
            System.out.println("  Selected " + b + " -- please pay " + b.recipe.priceCents + "c");
        }
        public void insertPayment(CoffeeMachine ctx, PaymentStrategy method, int amountCents) {
            System.out.println("  Reject: select a beverage first");
        }
        public void dispense(CoffeeMachine ctx) { System.out.println("  Reject: nothing selected"); }
        public void cancel(CoffeeMachine ctx) { System.out.println("  Nothing to cancel"); }
    }

    static final class SelectingState implements CoffeeMachineState {
        public void selectBeverage(CoffeeMachine ctx, Beverage b) { System.out.println("  Reject: already selecting"); }
        public void insertPayment(CoffeeMachine ctx, PaymentStrategy method, int amountCents) {
            int price = ctx.selectedBeverage.recipe.priceCents;
            if (amountCents < price) { System.out.println("  Reject: insufficient payment"); return; }
            ctx.paymentMethod = method;
            method.charge(price);
            ctx.setState(ctx.dispensingState);
            System.out.println("  Payment accepted -> dispensing");
        }
        public void dispense(CoffeeMachine ctx) { System.out.println("  Reject: pay first"); }
        public void cancel(CoffeeMachine ctx) {
            ctx.selectedBeverage = null;
            ctx.setState(ctx.idleState);
            System.out.println("  Selection cancelled");
        }
    }

    static final class DispensingState implements CoffeeMachineState {
        public void selectBeverage(CoffeeMachine ctx, Beverage b) { System.out.println("  Reject: already dispensing"); }
        public void insertPayment(CoffeeMachine ctx, PaymentStrategy method, int amountCents) { System.out.println("  Reject: already paid"); }
        public void dispense(CoffeeMachine ctx) { ctx.dispenseNow(); }
        public void cancel(CoffeeMachine ctx) {
            ctx.paymentMethod.refund(ctx.selectedBeverage.recipe.priceCents);
            ctx.selectedBeverage = null;
            ctx.setState(ctx.idleState);
            System.out.println("  Cancelled after payment; refunded");
        }
    }

    static final class OutOfStockState implements CoffeeMachineState {
        public void selectBeverage(CoffeeMachine ctx, Beverage b) { System.out.println("  Reject: machine out of stock"); }
        public void insertPayment(CoffeeMachine ctx, PaymentStrategy method, int amountCents) { System.out.println("  Reject: machine out of stock"); }
        public void dispense(CoffeeMachine ctx) { System.out.println("  Reject: machine out of stock"); }
        public void cancel(CoffeeMachine ctx) {
            ctx.selectedBeverage = null;
            ctx.setState(ctx.idleState);
            System.out.println("  Reset from out-of-stock -> idle");
        }
    }

    // ---------- CoffeeMachine (context/facade) ----------
    static final class CoffeeMachine {
        CoffeeMachineState currentState;
        final IdleState idleState = new IdleState();
        final SelectingState selectingState = new SelectingState();
        final DispensingState dispensingState = new DispensingState();
        final OutOfStockState outOfStockState = new OutOfStockState();

        final Map<String, Ingredient> ingredients = new HashMap<>();
        final Map<String, Beverage> beverages = new HashMap<>();
        Beverage selectedBeverage;
        PaymentStrategy paymentMethod;

        CoffeeMachine() { currentState = idleState; }

        void setState(CoffeeMachineState state) { currentState = state; }

        void addIngredient(Ingredient ing) { ingredients.put(ing.name, ing); }
        void addBeverage(Beverage b) { beverages.put(b.name, b); }

        void select(Beverage b) { currentState.selectBeverage(this, b); }
        void pay(PaymentStrategy method, int amountCents) { currentState.insertPayment(this, method, amountCents); }
        void dispense() { currentState.dispense(this); }
        void cancel() { currentState.cancel(this); }
        void refill(Ingredient ing, int qty) { ing.refill(qty); } // technician action, legal in any state

        /**
         * Reserve every required ingredient atomically-in-sequence; on the first shortfall, roll back
         * whatever was already reserved, refund, and flip to OutOfStockState. Never dispenses a half-made drink.
         */
        void dispenseNow() {
            Recipe recipe = selectedBeverage.recipe;
            List<Map.Entry<Ingredient, Integer>> reserved = new ArrayList<>();
            for (Map.Entry<Ingredient, Integer> e : recipe.requirements.entrySet()) {
                Ingredient ingredient = e.getKey();
                int qty = e.getValue();
                if (!ingredient.reserve(qty)) {
                    for (Map.Entry<Ingredient, Integer> r : reserved) r.getKey().release(r.getValue()); // roll back
                    paymentMethod.refund(recipe.priceCents);
                    setState(outOfStockState);
                    System.out.println("  Out of stock: missing " + ingredient.name + " -- refunded and rolled back");
                    return;
                }
                reserved.add(e);
            }
            // all ingredients reserved successfully -- safe to physically dispense
            System.out.println("  Pouring " + selectedBeverage + "...");
            selectedBeverage = null;
            setState(idleState);
        }
    }

    // ---------- Demo ----------
    public static void main(String[] args) {
        CoffeeMachine machine = new CoffeeMachine();

        Ingredient water = new Ingredient("water", 500, 1000);
        Ingredient milk = new Ingredient("milk", 500, 1000);
        Ingredient coffee = new Ingredient("coffee", 100, 500);
        Ingredient sugar = new Ingredient("sugar", 200, 500);
        machine.addIngredient(water);
        machine.addIngredient(milk);
        machine.addIngredient(coffee);
        machine.addIngredient(sugar);

        Beverage latte = new Beverage("Latte",
                new Recipe(Map.of(water, 100, milk, 150, coffee, 30), 250));
        Beverage espresso = new Beverage("Espresso",
                new Recipe(Map.of(water, 50, coffee, 40), 150));
        machine.addBeverage(latte);
        machine.addBeverage(espresso);

        System.out.println("-- Happy path: buy a Latte with cash --");
        machine.select(latte);
        CashPayment cash = new CashPayment();
        cash.insert(300);
        machine.pay(cash, 300);
        machine.dispense();

        System.out.println("\n-- Buy several Espressos with card until coffee runs out --");
        for (int i = 0; i < 12; i++) {
            machine.select(espresso);
            if (machine.currentState == machine.selectingState) {
                CardPayment card = new CardPayment();
                machine.pay(card, 150);
            }
            machine.dispense();
            if (machine.currentState == machine.outOfStockState) {
                System.out.println("  Machine reports out of stock after " + (i + 1) + " attempts");
                break;
            }
        }

        System.out.println("\n-- Technician refills coffee, machine recovers --");
        machine.refill(coffee, 200);
        machine.cancel(); // reset from OutOfStock -> Idle
        machine.select(espresso);
        CardPayment card2 = new CardPayment();
        machine.pay(card2, 150);
        machine.dispense();

        System.out.println("\n-- Cancel after paying (refund) --");
        machine.select(latte);
        CashPayment cash2 = new CashPayment();
        cash2.insert(250);
        machine.pay(cash2, 250);
        machine.cancel();
    }
}