Knowledge Guide
HomeHands-On BuildsLLD Katas

Design a Vending Machine

Design a Vending Machine

The vending machine is the canonical STATE-pattern interview question, for one precise reason: almost every candidate models it correctly on the whiteboard as three bubbles — Idle, HasMoney, Dispensing — and then, the moment they start typing, throws that diagram away and writes one enum status field with an if/else in every method. The bubbles were right. The code that follows them is where the bugs live: dispense without payment, coins accepted while the machine is sold out, a double-dispense on one payment. This lab has you build a vending machine from an empty file, in Java and Go, break the naive version on purpose, and see exactly why the State pattern isn't decoration — it's the thing that makes those two bugs impossible to write, not just easy to avoid. See also the State Pattern concept page for the theory side — do this lab first and that page will read like a victory lap.

1. The trap

You're asked to design a vending machine. You sketch three states on the whiteboard — Idle, HasMoney, Dispensing — and everyone nods. Then you start coding, and the "obvious" translation is one status field and a method per button:

// Abridged trap sketch -- illustrative only, not the full reference
// (the full compiled version, with both bugs live, is in Movement 5)
enum Status { IDLE, HAS_MONEY, DISPENSING, SOLD_OUT }

class NaiveVendingMachine {
    Status status = Status.IDLE;
    int balanceCents = 0;
    String selected;

    void insertCoin(int cents) {
        if (status == Status.DISPENSING) return;   // ...forgot SOLD_OUT
        balanceCents += cents;
        status = Status.HAS_MONEY;
    }

    void dispense() {
        if (selected == null) return;                // ...forgot to check balance >= price
        // ships the product regardless of whether balanceCents actually covers it
    }
}

This compiles. It demos fine on the happy path: insert a coin, select a product, dispense, done. The bugs are not typos — they're the structural consequence of the design. Every method has to independently re-derive, from a bare status field plus whatever ad-hoc flags accumulated around it, which of the other three-plus states it's legal to run from. insertCoin needs to remember to reject SOLD_OUT. dispense needs to remember to check the balance, not just "is something selected." selectProduct needs to remember to check stock and not clobber an in-flight dispense. Four states × four events is sixteen legality questions, and the enum+if version answers each one with a hand-written guard that has to be right every single time, forever, including the day someone adds a fifth event (a refund() button) or a sixth state (a Maintenance mode). Miss one guard and you get exactly what Movement 5 shows: a customer pays $0 and gets a soda, or the machine keeps taking coins after the shelf is empty. The bubbles on the whiteboard were the right model. The bug is that "one enum plus scattered if-checks" throws that model away and replaces it with sixteen independent chances to get a transition wrong.

2. Scope it like a senior

Before writing a line, pin the contract down. Questions worth asking out loud in an interview:

Answering these up front is what "senior" looks like: you're negotiating the spec, not guessing at it.

3. Reason to the design

Attempt 0 — the trap, in full. One status enum, balance and selection as loose fields, an if/else ladder in every method deciding what's legal. We just watched this fail in Movement 1 — not because anyone was careless, but because "is this transition legal" is a question answered sixteen separate times, once per state×event pair, each one a fresh chance to forget a check.

Attempt 1 — centralize the legality table. The next instinct is usually: keep the one status enum, but centralize the checks into a single Map<Status, Set<Event>> of "legal events per state," consulted at the top of every method before the enum-specific logic runs. This is a real improvement — it turns "did I forget a check" into "is the table right," which is easier to audit in one place. But it only answers whether a transition is legal; the what happens logic (accumulate balance, check stock, compute change, decide the next state) still lives back in the if/else ladder, unchanged. You've deduplicated the guard, not the mess behind it.

Attempt 2 — give each state its own object. Take the next step: instead of one class answering "what do I do for event X in state Y," make each state its own class that implements the same interface — one method per event — and only writes real logic for the events that are legal FROM that state. Every other method is a one-line rejection, because that's true by construction: SoldOutState doesn't have an if (status != SOLD_OUT) return guard anywhere, because there is no other status it could possibly be — you are only ever running SoldOutState's code when the machine actually is in that state. The context (the vending machine itself) holds a reference to whichever state object is current and forwards every event to it, then lets that state decide what the next state is. Nothing outside the four state classes needs to know the full legality table at all — it emerges from which class is currently plugged in.

Why this beats attempt 1, not just attempt 0: the centralized table tells you an event is illegal; the State pattern makes the illegal branch not exist in the state where it's illegal. There is no line of code anywhere that says "if state is IDLE, reject selectProduct" — IdleState.selectProduct() simply doesn't contain dispensing logic, full stop. Adding a fifth event later means adding one method to the VendingState interface and implementing it in each of the (still four) state classes — the compiler forces you to touch every state, so you cannot silently forget one, which is exactly the failure mode that produced both bugs in Movement 1.

4. Build it — milestones

Build a vending machine with this contract, then grow it milestone by milestone. Attempt each yourself before reading the reference implementation — the reveal is positioned after the design reasoning on purpose.

Reference implementation — Java (State pattern: one interface, four state classes, one context)

Five files, one package. VendingState is the interface; IdleState / HasMoneyState / DispensingState / SoldOutState are singletons (no per-instance data — all mutable state lives in the context); VendingMachine is the context that forwards every event to state and applies whatever transition the state decides on. Every method returns a message so the demo output below is the literal proof of what happened — not an assertion you have to trust.

// VendingState.java
package vending;

interface VendingState {
    String insertCoin(VendingMachine m, int cents);
    String selectProduct(VendingMachine m, String code);
    String dispense(VendingMachine m);
    String cancel(VendingMachine m);
}
// Product.java
package vending;

final class Product {
    final String code;
    final int priceCents;
    int stock;
    Product(String code, int priceCents, int stock) {
        this.code = code;
        this.priceCents = priceCents;
        this.stock = stock;
    }
}
// IdleState.java
package vending;

final class IdleState implements VendingState {
    static final IdleState INSTANCE = new IdleState();
    private IdleState() {}

    public String insertCoin(VendingMachine m, int cents) {
        m.addBalance(cents);
        m.setState(HasMoneyState.INSTANCE);
        return "accepted " + cents + "c, balance=" + m.getBalance() + "c -> HAS_MONEY";
    }
    public String selectProduct(VendingMachine m, String code) {
        return "REJECTED: insert coins before selecting a product (state=IDLE)";
    }
    public String dispense(VendingMachine m) {
        return "REJECTED: nothing to dispense (state=IDLE)";
    }
    public String cancel(VendingMachine m) {
        return "REJECTED: nothing to cancel (state=IDLE)";
    }
}
// HasMoneyState.java
package vending;

final class HasMoneyState implements VendingState {
    static final HasMoneyState INSTANCE = new HasMoneyState();
    private HasMoneyState() {}

    public String insertCoin(VendingMachine m, int cents) {
        m.addBalance(cents);
        return "accepted " + cents + "c, balance=" + m.getBalance() + "c (still HAS_MONEY)";
    }
    public String selectProduct(VendingMachine m, String code) {
        Product p = m.product(code);
        if (p == null) return "REJECTED: unknown product " + code;
        if (p.stock <= 0) return "REJECTED: " + code + " is sold out -- pick another or cancel";
        int shortfall = p.priceCents - m.getBalance();
        if (shortfall > 0) return "REJECTED: insufficient funds, insert " + shortfall + "c more";
        m.setSelected(code);
        m.setState(DispensingState.INSTANCE);
        return code + " selected, committed -> DISPENSING (call dispense())";
    }
    public String dispense(VendingMachine m) {
        return "REJECTED: select a product before dispensing (state=HAS_MONEY)";
    }
    public String cancel(VendingMachine m) {
        int refund = m.getBalance();
        m.resetBalance();
        m.setState(IdleState.INSTANCE);
        return "cancelled, refunded " + refund + "c -> IDLE";
    }
}
// DispensingState.java
package vending;

import java.util.Map;

final class DispensingState implements VendingState {
    static final DispensingState INSTANCE = new DispensingState();
    private DispensingState() {}

    public String insertCoin(VendingMachine m, int cents) {
        return "REJECTED: cannot insert coins mid-dispense (state=DISPENSING)";
    }
    public String selectProduct(VendingMachine m, String code) {
        return "REJECTED: already dispensing " + m.getSelected() + " (state=DISPENSING)";
    }
    public String dispense(VendingMachine m) {
        Product p = m.product(m.getSelected());
        int changeDue = m.getBalance() - p.priceCents;
        Map<Integer, Integer> change = m.makeChange(changeDue);
        if (change == null) {
            // can't make exact change -- abort the sale, refund in full, stock untouched
            int refund = m.getBalance();
            m.resetBalance();
            m.clearSelected();
            m.setState(m.hasAnyStock() ? IdleState.INSTANCE : SoldOutState.INSTANCE);
            return "ABORTED: cannot make " + changeDue + "c exact change -- refunded " + refund + "c -> IDLE";
        }
        p.stock -= 1;
        m.commitChange(change);
        m.resetBalance();
        String dispensedCode = p.code;
        m.clearSelected();
        m.setState(m.hasAnyStock() ? IdleState.INSTANCE : SoldOutState.INSTANCE);
        return "DISPENSED " + dispensedCode + ", change=" + changeDue + "c as " + change + " -> IDLE";
    }
    public String cancel(VendingMachine m) {
        return "REJECTED: cannot cancel mid-dispense, already committed (state=DISPENSING)";
    }
}
// SoldOutState.java
package vending;

final class SoldOutState implements VendingState {
    static final SoldOutState INSTANCE = new SoldOutState();
    private SoldOutState() {}

    public String insertCoin(VendingMachine m, int cents) {
        return "REJECTED: machine sold out, coin returned (state=SOLD_OUT)";
    }
    public String selectProduct(VendingMachine m, String code) {
        return "REJECTED: machine sold out (state=SOLD_OUT)";
    }
    public String dispense(VendingMachine m) {
        return "REJECTED: nothing to dispense (state=SOLD_OUT)";
    }
    public String cancel(VendingMachine m) {
        return "REJECTED: nothing to cancel (state=SOLD_OUT)";
    }
}
// VendingMachine.java -- the context
package vending;

import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeMap;

public final class VendingMachine {
    private VendingState state = IdleState.INSTANCE;
    private final Map<String, Product> catalog = new LinkedHashMap<String, Product>();
    private int balanceCents = 0;
    private String selectedCode;
    // denom (cents) -> count on hand; reverse order so makeChange sees largest first
    private final TreeMap<Integer, Integer> changeReserve = new TreeMap<Integer, Integer>(Collections.reverseOrder());

    // -- setup, used by the harness/tests, not part of the state machine itself --
    public void stock(String code, int priceCents, int qty) {
        Product p = catalog.get(code);
        if (p == null) {
            p = new Product(code, priceCents, 0);
            catalog.put(code, p);
        }
        p.stock += qty;
        if (state instanceof SoldOutState && hasAnyStock()) {
            state = IdleState.INSTANCE;
        }
    }
    public void loadChange(int denomCents, int count) {
        Integer cur = changeReserve.get(denomCents);
        changeReserve.put(denomCents, (cur == null ? 0 : cur) + count);
    }

    // -- the four events; each just forwards to the current state. synchronized
    // models the single physical machine: only one event is ever in flight --
    public synchronized String insertCoin(int cents) { return state.insertCoin(this, cents); }
    public synchronized String selectProduct(String code) { return state.selectProduct(this, code); }
    public synchronized String dispense() { return state.dispense(this); }
    public synchronized String cancel() { return state.cancel(this); }

    public String currentState() { return state.getClass().getSimpleName(); }

    // -- package-private hooks used only by VendingState implementations --
    void setState(VendingState s) { this.state = s; }
    void addBalance(int cents) { this.balanceCents += cents; }
    void resetBalance() { this.balanceCents = 0; }
    int getBalance() { return balanceCents; }
    void setSelected(String code) { this.selectedCode = code; }
    void clearSelected() { this.selectedCode = null; }
    String getSelected() { return selectedCode; }
    Product product(String code) { return catalog.get(code); }
    boolean hasAnyStock() {
        for (Product p : catalog.values()) {
            if (p.stock > 0) return true;
        }
        return false;
    }

    /** Greedy coin change: largest denomination first. Returns null if the
     *  reserve cannot make EXACT change -- the edge naive implementations forget. */
    Map<Integer, Integer> makeChange(int amountCents) {
        Map<Integer, Integer> result = new LinkedHashMap<Integer, Integer>();
        int remaining = amountCents;
        for (Map.Entry<Integer, Integer> e : changeReserve.entrySet()) {
            int denom = e.getKey();
            int available = e.getValue();
            int use = Math.min(available, remaining / denom);
            if (use > 0) {
                result.put(denom, use);
                remaining -= use * denom;
            }
        }
        return remaining == 0 ? result : null;
    }
    void commitChange(Map<Integer, Integer> change) {
        for (Map.Entry<Integer, Integer> e : change.entrySet()) {
            Integer cur = changeReserve.get(e.getKey());
            changeReserve.put(e.getKey(), (cur == null ? 0 : cur) - e.getValue());
        }
    }
}

StateMachineDemo.java walks M1–M4 end to end (default package, import vending.VendingMachine;):

// StateMachineDemo.java (default package)
import vending.VendingMachine;

/** Walks M1 -> M4 against the State-pattern machine: legal happy paths only. */
public final class StateMachineDemo {
    public static void main(String[] args) {
        VendingMachine m = new VendingMachine();
        m.stock("A1", 150, 2);   // 2x $1.50 soda
        m.stock("B2", 225, 1);   // 1x $2.25 chips
        m.loadChange(100, 3);
        m.loadChange(25, 10);
        m.loadChange(5, 20);

        System.out.println("-- M1/M2: insert, select, dispense, change --");
        System.out.println(m.insertCoin(100));
        System.out.println(m.insertCoin(100));            // balance=200c
        System.out.println(m.selectProduct("A1"));         // price 150, commits -> DISPENSING
        System.out.println(m.dispense());                  // change = 50c

        System.out.println();
        System.out.println("-- M2: insufficient funds, then top up --");
        System.out.println(m.insertCoin(100));
        System.out.println(m.selectProduct("B2"));          // price 225, shortfall 125 -> REJECTED
        System.out.println(m.insertCoin(125));
        System.out.println(m.selectProduct("B2"));          // now commits
        System.out.println(m.dispense());

        System.out.println();
        System.out.println("-- M2: sold-out product, pick a different one --");
        System.out.println(m.insertCoin(150));
        System.out.println(m.selectProduct("B2"));          // B2 stock now 0 -> REJECTED sold out
        System.out.println(m.selectProduct("A1"));          // still 1 left -> commits
        System.out.println(m.dispense());
        System.out.println("state = " + m.currentState());  // both products at 0 -> SOLD_OUT

        System.out.println();
        System.out.println("-- M4: cancel refunds mid-transaction --");
        m.stock("A1", 150, 1);                              // restock -> back to IDLE
        System.out.println(m.insertCoin(100));
        System.out.println(m.cancel());                     // refunds 100c, back to IDLE
    }
}

Actual captured output from java StateMachineDemo:

-- M1/M2: insert, select, dispense, change --
accepted 100c, balance=100c -> HAS_MONEY
accepted 100c, balance=200c (still HAS_MONEY)
A1 selected, committed -> DISPENSING (call dispense())
DISPENSED A1, change=50c as {25=2} -> IDLE

-- M2: insufficient funds, then top up --
accepted 100c, balance=100c -> HAS_MONEY
REJECTED: insufficient funds, insert 125c more
accepted 125c, balance=225c (still HAS_MONEY)
B2 selected, committed -> DISPENSING (call dispense())
DISPENSED B2, change=0c as {} -> IDLE

-- M2: sold-out product, pick a different one --
accepted 150c, balance=150c -> HAS_MONEY
REJECTED: B2 is sold out -- pick another or cancel
A1 selected, committed -> DISPENSING (call dispense())
DISPENSED A1, change=0c as {} -> IDLE
state = SoldOutState

-- M4: cancel refunds mid-transaction --
accepted 100c, balance=100c -> HAS_MONEY
cancelled, refunded 100c -> IDLE

Compiled clean with javac -Xlint:all (zero warnings) and executed before writing this page.

Reference implementation — Go (same State pattern: an interface + empty structs as states)

Go has no singleton-object idiom, so each state is a zero-size struct with value-receiver methods — functionally identical to the Java singletons, just spelled the Go way. All files share package main so the module builds as one binary.

// state.go
package main

// VendingState is the State interface: one method per event, each returning
// a human-readable outcome message. Concrete states implement only the legal
// transitions FROM that state -- everything else returns a rejection string.
// Illegal events are refused BY CONSTRUCTION, never a runtime check you hope
// you remembered to write.
type VendingState interface {
	InsertCoin(m *VendingMachine, cents int) string
	SelectProduct(m *VendingMachine, code string) string
	Dispense(m *VendingMachine) string
	Cancel(m *VendingMachine) string
}
// product.go
package main

// Product is a catalog entry: a price and remaining stock count.
type Product struct {
	Code       string
	PriceCents int
	Stock      int
}
// idle.go
package main

import "fmt"

type idleState struct{}

func (idleState) InsertCoin(m *VendingMachine, cents int) string {
	m.balanceCents += cents
	m.state = hasMoneyState{}
	return fmt.Sprintf("accepted %dc, balance=%dc -> HAS_MONEY", cents, m.balanceCents)
}
func (idleState) SelectProduct(m *VendingMachine, code string) string {
	return "REJECTED: insert coins before selecting a product (state=IDLE)"
}
func (idleState) Dispense(m *VendingMachine) string {
	return "REJECTED: nothing to dispense (state=IDLE)"
}
func (idleState) Cancel(m *VendingMachine) string {
	return "REJECTED: nothing to cancel (state=IDLE)"
}
// hasmoney.go
package main

import "fmt"

type hasMoneyState struct{}

func (hasMoneyState) InsertCoin(m *VendingMachine, cents int) string {
	m.balanceCents += cents
	return fmt.Sprintf("accepted %dc, balance=%dc (still HAS_MONEY)", cents, m.balanceCents)
}
func (hasMoneyState) SelectProduct(m *VendingMachine, code string) string {
	p, ok := m.catalog[code]
	if !ok {
		return fmt.Sprintf("REJECTED: unknown product %s", code)
	}
	if p.Stock <= 0 {
		return fmt.Sprintf("REJECTED: %s is sold out -- pick another or cancel", code)
	}
	shortfall := p.PriceCents - m.balanceCents
	if shortfall > 0 {
		return fmt.Sprintf("REJECTED: insufficient funds, insert %dc more", shortfall)
	}
	m.selectedCode = code
	m.state = dispensingState{}
	return fmt.Sprintf("%s selected, committed -> DISPENSING (call Dispense())", code)
}
func (hasMoneyState) Dispense(m *VendingMachine) string {
	return "REJECTED: select a product before dispensing (state=HAS_MONEY)"
}
func (hasMoneyState) Cancel(m *VendingMachine) string {
	refund := m.balanceCents
	m.balanceCents = 0
	m.state = idleState{}
	return fmt.Sprintf("cancelled, refunded %dc -> IDLE", refund)
}
// dispensing.go
package main

import "fmt"

type dispensingState struct{}

func (dispensingState) InsertCoin(m *VendingMachine, cents int) string {
	return "REJECTED: cannot insert coins mid-dispense (state=DISPENSING)"
}
func (dispensingState) SelectProduct(m *VendingMachine, code string) string {
	return fmt.Sprintf("REJECTED: already dispensing %s (state=DISPENSING)", m.selectedCode)
}
func (dispensingState) Dispense(m *VendingMachine) string {
	p := m.catalog[m.selectedCode]
	changeDue := m.balanceCents - p.PriceCents
	change, ok := m.makeChange(changeDue)
	if !ok {
		// can't make exact change -- abort the sale, refund in full, stock untouched
		refund := m.balanceCents
		m.balanceCents = 0
		m.selectedCode = ""
		m.state = nextIdleOrSoldOut(m)
		return fmt.Sprintf("ABORTED: cannot make %dc exact change -- refunded %dc -> IDLE", changeDue, refund)
	}
	p.Stock--
	m.commitChange(change)
	m.balanceCents = 0
	dispensedCode := p.Code
	m.selectedCode = ""
	m.state = nextIdleOrSoldOut(m)
	return fmt.Sprintf("DISPENSED %s, change=%dc as %v -> IDLE", dispensedCode, changeDue, change)
}
func (dispensingState) Cancel(m *VendingMachine) string {
	return "REJECTED: cannot cancel mid-dispense, already committed (state=DISPENSING)"
}

func nextIdleOrSoldOut(m *VendingMachine) VendingState {
	if m.hasAnyStock() {
		return idleState{}
	}
	return soldOutState{}
}
// soldout.go
package main

type soldOutState struct{}

func (soldOutState) InsertCoin(m *VendingMachine, cents int) string {
	return "REJECTED: machine sold out, coin returned (state=SOLD_OUT)"
}
func (soldOutState) SelectProduct(m *VendingMachine, code string) string {
	return "REJECTED: machine sold out (state=SOLD_OUT)"
}
func (soldOutState) Dispense(m *VendingMachine) string {
	return "REJECTED: nothing to dispense (state=SOLD_OUT)"
}
func (soldOutState) Cancel(m *VendingMachine) string {
	return "REJECTED: nothing to cancel (state=SOLD_OUT)"
}
// machine.go -- the context
package main

import (
	"fmt"
	"sort"
	"sync"
)

// VendingMachine is the context: it holds the current state and forwards
// every event to it. sync.Mutex models the single physical machine -- only
// one event is ever in flight, exactly like one coin slot and one chute.
type VendingMachine struct {
	mu            sync.Mutex
	state         VendingState
	catalog       map[string]*Product
	balanceCents  int
	selectedCode  string
	changeReserve map[int]int
}

func NewVendingMachine() *VendingMachine {
	return &VendingMachine{
		state:         idleState{},
		catalog:       make(map[string]*Product),
		changeReserve: make(map[int]int),
	}
}

func (m *VendingMachine) Stock(code string, priceCents, qty int) {
	p, ok := m.catalog[code]
	if !ok {
		p = &Product{Code: code, PriceCents: priceCents}
		m.catalog[code] = p
	}
	p.Stock += qty
	if _, soldOut := m.state.(soldOutState); soldOut && m.hasAnyStock() {
		m.state = idleState{}
	}
}
func (m *VendingMachine) LoadChange(denomCents, count int) { m.changeReserve[denomCents] += count }

// the four events -- each just forwards to the current state under one lock.
func (m *VendingMachine) InsertCoin(cents int) string {
	m.mu.Lock()
	defer m.mu.Unlock()
	return m.state.InsertCoin(m, cents)
}
func (m *VendingMachine) SelectProduct(code string) string {
	m.mu.Lock()
	defer m.mu.Unlock()
	return m.state.SelectProduct(m, code)
}
func (m *VendingMachine) Dispense() string {
	m.mu.Lock()
	defer m.mu.Unlock()
	return m.state.Dispense(m)
}
func (m *VendingMachine) Cancel() string {
	m.mu.Lock()
	defer m.mu.Unlock()
	return m.state.Cancel(m)
}

func (m *VendingMachine) CurrentState() string {
	switch m.state.(type) {
	case idleState:
		return "IdleState"
	case hasMoneyState:
		return "HasMoneyState"
	case dispensingState:
		return "DispensingState"
	case soldOutState:
		return "SoldOutState"
	default:
		return fmt.Sprintf("%T", m.state)
	}
}

func (m *VendingMachine) hasAnyStock() bool {
	for _, p := range m.catalog {
		if p.Stock > 0 {
			return true
		}
	}
	return false
}

// makeChange is greedy coin change: largest denomination first. Returns
// (nil, false) if the reserve cannot make EXACT change -- the edge naive
// implementations forget.
func (m *VendingMachine) makeChange(amount int) (map[int]int, bool) {
	denoms := make([]int, 0, len(m.changeReserve))
	for d := range m.changeReserve {
		denoms = append(denoms, d)
	}
	sort.Sort(sort.Reverse(sort.IntSlice(denoms)))
	result := make(map[int]int)
	remaining := amount
	for _, d := range denoms {
		avail := m.changeReserve[d]
		use := remaining / d
		if use > avail {
			use = avail
		}
		if use > 0 {
			result[d] = use
			remaining -= use * d
		}
	}
	if remaining != 0 {
		return nil, false
	}
	return result, true
}
func (m *VendingMachine) commitChange(change map[int]int) {
	for d, n := range change {
		m.changeReserve[d] -= n
	}
}

runStateMachineDemo() walks M1–M4 end to end, mirroring the Java StateMachineDemo exactly (it's one of the three functions demo.go's main() calls — the full demo.go is in Movement 5, alongside the naive machine it contrasts with):

// runStateMachineDemo -- part of demo.go, called from main()
func runStateMachineDemo() {
	fmt.Println("== State-pattern machine: M1-M4 happy paths ==")
	m := NewVendingMachine()
	m.Stock("A1", 150, 2) // 2x $1.50 soda
	m.Stock("B2", 225, 1) // 1x $2.25 chips
	m.LoadChange(100, 3)
	m.LoadChange(25, 10)
	m.LoadChange(5, 20)

	fmt.Println("-- M1/M2: insert, select, dispense, change --")
	fmt.Println(m.InsertCoin(100))
	fmt.Println(m.InsertCoin(100))
	fmt.Println(m.SelectProduct("A1"))
	fmt.Println(m.Dispense())

	fmt.Println()
	fmt.Println("-- M2: insufficient funds, then top up --")
	fmt.Println(m.InsertCoin(100))
	fmt.Println(m.SelectProduct("B2"))
	fmt.Println(m.InsertCoin(125))
	fmt.Println(m.SelectProduct("B2"))
	fmt.Println(m.Dispense())

	fmt.Println()
	fmt.Println("-- M2: sold-out product, pick a different one --")
	fmt.Println(m.InsertCoin(150))
	fmt.Println(m.SelectProduct("B2"))
	fmt.Println(m.SelectProduct("A1"))
	fmt.Println(m.Dispense())
	fmt.Println("state =", m.CurrentState())

	fmt.Println()
	fmt.Println("-- M4: cancel refunds mid-transaction --")
	m.Stock("A1", 150, 1)
	fmt.Println(m.InsertCoin(100))
	fmt.Println(m.Cancel())
}

Actual captured output from go run . — identical shape to the Java run, modulo Go's map-printing format:

== State-pattern machine: M1-M4 happy paths ==
-- M1/M2: insert, select, dispense, change --
accepted 100c, balance=100c -> HAS_MONEY
accepted 100c, balance=200c (still HAS_MONEY)
A1 selected, committed -> DISPENSING (call Dispense())
DISPENSED A1, change=50c as map[25:2] -> IDLE

-- M2: insufficient funds, then top up --
accepted 100c, balance=100c -> HAS_MONEY
REJECTED: insufficient funds, insert 125c more
accepted 125c, balance=225c (still HAS_MONEY)
B2 selected, committed -> DISPENSING (call Dispense())
DISPENSED B2, change=0c as map[] -> IDLE

-- M2: sold-out product, pick a different one --
accepted 150c, balance=150c -> HAS_MONEY
REJECTED: B2 is sold out -- pick another or cancel
A1 selected, committed -> DISPENSING (call Dispense())
DISPENSED A1, change=0c as map[] -> IDLE
state = SoldOutState

-- M4: cancel refunds mid-transaction --
accepted 100c, balance=100c -> HAS_MONEY
cancelled, refunded 100c -> IDLE

Passes gofmt -l (clean), go vet ./... (clean), go build ./... (clean) — verified before publishing.

5. Break it — the test that fails

Movement 1's trap wasn't hand-waving — here is the actual naive class, and the actual two illegal transitions it allows, run against the actual State-pattern machine above for direct comparison. Full file, copy-paste compilable:

// NaiveVendingMachine.java (default package)
import java.util.HashMap;
import java.util.Map;

/**
 * Movement 1's "trap": one enum status + a couple of ad-hoc maps, with an
 * if/else in every method re-deriving which transitions are "supposed" to
 * be legal. It compiles. It works on the happy path. The bugs below are the
 * natural consequence of every method reasoning about legality from scratch.
 */
public final class NaiveVendingMachine {
    enum Status { IDLE, HAS_MONEY, DISPENSING, SOLD_OUT }

    private Status status = Status.IDLE;
    private int balanceCents = 0;
    private String selected;
    private final Map<String, Integer> price = new HashMap<String, Integer>();
    private final Map<String, Integer> stock = new HashMap<String, Integer>();

    public NaiveVendingMachine() {
        price.put("A1", 150);
        stock.put("A1", 1);
        price.put("A2", 200);
        stock.put("A2", 0);   // A2 starts sold out
    }

    /** BUG #1: only excludes DISPENSING -- forgets SOLD_OUT, so coins are
     *  silently accepted (and status silently flips to HAS_MONEY) even when
     *  the machine has nothing left to sell. */
    public String insertCoin(int cents) {
        if (status == Status.DISPENSING) {
            return "rejected: mid-dispense";
        }
        balanceCents += cents;
        status = Status.HAS_MONEY;
        return "accepted " + cents + "c, balance=" + balanceCents + "c, status=" + status;
    }

    public String selectProduct(String code) {
        Integer have = stock.get(code);
        if (have == null || have <= 0) {
            status = Status.SOLD_OUT;
            return "sold out: " + code;
        }
        // BUG #2: never checks balanceCents against price.get(code) here --
        // the author assumed dispense() would check it. It doesn't.
        selected = code;
        return code + " selected";
    }

    /** BUG #2 (continued): only checks that something is selected -- never
     *  checks status == HAS_MONEY, never checks balanceCents >= price. Call
     *  this right after selectProduct() with a zero balance and it dispenses. */
    public String dispense() {
        if (selected == null) return "rejected: nothing selected";
        int cost = price.get(selected);
        stock.put(selected, stock.get(selected) - 1);
        int change = balanceCents - cost;   // can go NEGATIVE -- nobody paid enough
        String result = "DISPENSED " + selected + ", change=" + change
                + "c (balance was " + balanceCents + "c, cost=" + cost + "c)";
        balanceCents = 0;
        selected = null;
        status = Status.IDLE;
        return result;
    }

    public String status() { return status.toString(); }
}
// BreakItDemo.java (default package)
import vending.VendingMachine;

/**
 * Run the exact same two illegal sequences against the naive enum+if
 * machine and the State-pattern machine. The naive one corrupts itself
 * silently; the State machine rejects both, by construction, with no
 * special-case guard bolted on anywhere.
 */
public final class BreakItDemo {
    public static void main(String[] args) {
        System.out.println("== naive enum+if machine ==");
        NaiveVendingMachine naive = new NaiveVendingMachine();

        System.out.println("[bug 1: dispense without payment]");
        System.out.println(naive.selectProduct("A1"));   // balance is still 0
        System.out.println(naive.dispense());            // DISPENSES with change=-150c !!

        System.out.println();
        System.out.println("[bug 2: accept coins when sold out]");
        System.out.println(naive.selectProduct("A2"));   // A2 has 0 stock -> SOLD_OUT
        System.out.println(naive.insertCoin(100));       // silently ACCEPTED, status flips back
        System.out.println("naive.status() = " + naive.status());

        System.out.println();
        System.out.println("== State-pattern machine (same two illegal sequences) ==");
        VendingMachine sm = new VendingMachine();
        sm.stock("A1", 150, 1);
        sm.stock("A2", 200, 0);
        sm.loadChange(100, 5);
        sm.loadChange(25, 10);

        System.out.println("[attempt: dispense without payment]");
        System.out.println(sm.selectProduct("A1"));      // REJECTED: insert coins first (state=IDLE)
        System.out.println(sm.dispense());               // REJECTED: nothing selected (state=IDLE)

        System.out.println();
        System.out.println("[drain the only stocked item the LEGAL way, to reach SOLD_OUT honestly]");
        System.out.println(sm.insertCoin(150));
        System.out.println(sm.selectProduct("A1"));
        System.out.println(sm.dispense());
        System.out.println("state after drain = " + sm.currentState());

        System.out.println();
        System.out.println("[attempt: accept coins when sold out]");
        System.out.println(sm.insertCoin(100));          // REJECTED: machine sold out, coin returned
    }
}

Run it — actual output, captured verbatim from java BreakItDemo:

== naive enum+if machine ==
[bug 1: dispense without payment]
A1 selected
DISPENSED A1, change=-150c (balance was 0c, cost=150c)

[bug 2: accept coins when sold out]
sold out: A2
accepted 100c, balance=100c, status=HAS_MONEY
naive.status() = HAS_MONEY

== State-pattern machine (same two illegal sequences) ==
[attempt: dispense without payment]
REJECTED: insert coins before selecting a product (state=IDLE)
REJECTED: nothing to dispense (state=IDLE)

[drain the only stocked item the LEGAL way, to reach SOLD_OUT honestly]
accepted 150c, balance=150c -> HAS_MONEY
A1 selected, committed -> DISPENSING (call dispense())
DISPENSED A1, change=0c as {} -> IDLE
state after drain = SoldOutState

[attempt: accept coins when sold out]
REJECTED: machine sold out, coin returned (state=SOLD_OUT)

The naive machine dispensed a $1.50 product for $0 and printed a change of -150c — a negative change amount is not a display quirk, it's a receipt telling you the machine just gave away real inventory for nothing. Then it accepted a coin from a customer standing in front of a machine that has already told itself (via status = SOLD_OUT) that it has nothing left to sell. Neither bug required a race, a rare interleaving, or bad luck — both reproduce on every single run, single-threaded, because the naive design never had a mechanism to make them structurally impossible; it only had a mechanism to make them likely to be checked, if the author remembered. The State-pattern machine rejects the identical two attempts with a clear reason and zero corrupted state, and it does so without a single line of code anywhere that says "if sold out, reject coins" — SoldOutState.insertCoin() simply has no other behavior to fall back to.

The Go version reproduces the identical shape of both bugs and both rejections deterministically under go run . (from the consolidated demo.go — Go allows only one main() per binary, so all three movements' demos run from one entry point, clearly delimited by section headers in the output):

== naive enum+if machine ==
[bug 1: dispense without payment]
A1 selected
DISPENSED A1, change=-150c (balance was 0c, cost=150c)

[bug 2: accept coins when sold out]
sold out: A2
accepted 100c, balance=100c, status=1
naive.status = 1

== State-pattern machine (same two illegal sequences) ==
[attempt: dispense without payment]
REJECTED: insert coins before selecting a product (state=IDLE)
REJECTED: nothing to dispense (state=IDLE)

[drain the only stocked item the LEGAL way, to reach SOLD_OUT honestly]
accepted 150c, balance=150c -> HAS_MONEY
A1 selected, committed -> DISPENSING (call Dispense())
DISPENSED A1, change=0c as map[] -> IDLE
state after drain = SoldOutState

[attempt: accept coins when sold out]
REJECTED: machine sold out, coin returned (state=SOLD_OUT)

(naive.status prints as the raw iota int 1 in Go rather than Java's enum name — that's a language-idiom difference, not a different bug; it's still the same silent HAS_MONEY flip after a sold-out product was selected.) The naive struct, full source, same shape as the Java class above just spelled with Go idioms (a small naiveStatus int with iota constants instead of an enum):

// naive.go
package main

import "fmt"

// naiveVendingMachine mirrors Movement 1's trap: one status field and a
// couple of maps, with each method re-deriving what's "supposed" to be
// legal from scratch.
type naiveStatus int

const (
	naiveIdle naiveStatus = iota
	naiveHasMoney
	naiveDispensing
	naiveSoldOut
)

type naiveVendingMachine struct {
	status       naiveStatus
	balanceCents int
	selected     string
	price        map[string]int
	stock        map[string]int
}

func newNaiveVendingMachine() *naiveVendingMachine {
	return &naiveVendingMachine{
		status: naiveIdle,
		price:  map[string]int{"A1": 150, "A2": 200},
		stock:  map[string]int{"A1": 1, "A2": 0}, // A2 starts sold out
	}
}

// BUG #1: only excludes naiveDispensing -- forgets naiveSoldOut, so coins
// are silently accepted even when the machine has nothing left to sell.
func (n *naiveVendingMachine) insertCoin(cents int) string {
	if n.status == naiveDispensing {
		return "rejected: mid-dispense"
	}
	n.balanceCents += cents
	n.status = naiveHasMoney
	return fmt.Sprintf("accepted %dc, balance=%dc, status=%d", cents, n.balanceCents, n.status)
}

func (n *naiveVendingMachine) selectProduct(code string) string {
	have := n.stock[code]
	if have <= 0 {
		n.status = naiveSoldOut
		return "sold out: " + code
	}
	// BUG #2: never checks n.balanceCents against n.price[code].
	n.selected = code
	return code + " selected"
}

// BUG #2 (continued): only checks that something is selected -- never
// checks status or balance. Call right after selectProduct() with a zero
// balance and it dispenses anyway.
func (n *naiveVendingMachine) dispense() string {
	if n.selected == "" {
		return "rejected: nothing selected"
	}
	cost := n.price[n.selected]
	n.stock[n.selected]--
	change := n.balanceCents - cost // can go NEGATIVE
	result := fmt.Sprintf("DISPENSED %s, change=%dc (balance was %dc, cost=%dc)",
		n.selected, change, n.balanceCents, cost)
	n.balanceCents = 0
	n.selected = ""
	n.status = naiveIdle
	return result
}

And the Go demo entry point (Go permits exactly one main() per binary, so it drives all three movements' demos from one place):

// demo.go
package main

import "fmt"

func main() {
	runStateMachineDemo()
	fmt.Println()
	runBreakItDemo()
	fmt.Println()
	runChangeAlgorithmsDemo()
}

func runBreakItDemo() {
	fmt.Println("== naive enum+if machine ==")
	naive := newNaiveVendingMachine()

	fmt.Println("[bug 1: dispense without payment]")
	fmt.Println(naive.selectProduct("A1"))
	fmt.Println(naive.dispense())

	fmt.Println()
	fmt.Println("[bug 2: accept coins when sold out]")
	fmt.Println(naive.selectProduct("A2"))
	fmt.Println(naive.insertCoin(100))
	fmt.Printf("naive.status = %d\n", naive.status)

	fmt.Println()
	fmt.Println("== State-pattern machine (same two illegal sequences) ==")
	sm := NewVendingMachine()
	sm.Stock("A1", 150, 1)
	sm.Stock("A2", 200, 0)
	sm.LoadChange(100, 5)
	sm.LoadChange(25, 10)

	fmt.Println("[attempt: dispense without payment]")
	fmt.Println(sm.SelectProduct("A1"))
	fmt.Println(sm.Dispense())

	fmt.Println()
	fmt.Println("[drain the only stocked item the LEGAL way, to reach SOLD_OUT honestly]")
	fmt.Println(sm.InsertCoin(150))
	fmt.Println(sm.SelectProduct("A1"))
	fmt.Println(sm.Dispense())
	fmt.Println("state after drain =", sm.CurrentState())

	fmt.Println()
	fmt.Println("[attempt: accept coins when sold out]")
	fmt.Println(sm.InsertCoin(100))
}

(runStateMachineDemo, shown in Movement 4, and runChangeAlgorithmsDemo, shown in Movement 6, are the other two functions this main() calls — every function referenced anywhere on this page is defined somewhere on this page, nothing is hand-waved.)

6. Optimise — with trade-offs

ApproachIllegal transitionsAdding a new stateAdding a new eventUse when
State pattern (this lab)Structurally impossible where the method doesn't exist — nothing to forgetNew class implementing the interface; existing states untouchedNew interface method; compiler forces every state to implement it (or explicitly no-op it) — can't silently skip one≥3 states or the transition table is likely to grow (new payment types, new machine modes) — the default choice for this problem
enum + switch/if-else (Movement 1's trap)Possible and easy — each of the (states × events) legality checks is a separate hand-written guardTouch every switch statement in every method to add the new caseTouch every state's branch, in every method, by hand — nothing forces completeness2 states, one event, a genuinely trivial machine that will never grow — otherwise it's a trap, not a shortcut
Explicit transition table (Map<State, Map<Event, State>>)The allowed next-state question is data-driven and easy to audit in one place — but the side effects (charge balance, decrement stock, compute change) still need code per transition, defeating some of the winAdd rows/entries to the table — no new classAdd a column; still need to write the side-effect code somewhere and wire it to the table lookupThe state graph itself is complex and worth visualizing/validating (e.g. as data loaded from config) but each transition's side effect is simple or shared — a middle ground, not a universal upgrade over State

The real judgment call: State and the transition table aren't really competitors — the transition table is a data-driven variant of the same idea (legality lives in one place, not scattered), while raw enum+switch is the thing both of them are correctly replacing. Reach for the explicit table when the interesting complexity is the shape of the graph itself (which transitions exist, useful to visualize or validate against a spec) and the per-transition logic is thin; reach for full State-pattern classes (this lab) when the interesting complexity is what happens during each transition — computing change, checking stock, refunding — because that logic needs a real method body, not a table cell.

Greedy vs. DP change-making — where greedy provably breaks. M3's makeChange is greedy: take as many of the largest denomination as fit, then the next, and so on. That's optimal (fewest coins) for a canonical coin system — and US currency (25/10/5/1) is canonical, so greedy is not just simple, it's provably correct there. It is not generally optimal, and the standard counterexample is denominations {1, 3, 4} needing amount 6:

// ChangeAlgorithms.java -- greedy vs DP, same problem, run and captured
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public final class ChangeAlgorithms {

    static List<Integer> greedy(int amount, int[] denomsDesc) {
        List<Integer> used = new ArrayList<Integer>();
        int remaining = amount;
        for (int d : denomsDesc) {
            while (remaining >= d) { used.add(d); remaining -= d; }
        }
        if (remaining != 0) throw new IllegalStateException("greedy could not make exact change");
        return used;
    }

    /** Minimum-coins DP: dp[a] = fewest coins to make amount a. */
    static List<Integer> dpMinCoins(int amount, int[] denoms) {
        int[] dp = new int[amount + 1];
        int[] lastCoin = new int[amount + 1];
        Arrays.fill(dp, Integer.MAX_VALUE);
        dp[0] = 0;
        for (int a = 1; a <= amount; a++) {
            for (int d : denoms) {
                if (d <= a && dp[a - d] != Integer.MAX_VALUE && dp[a - d] + 1 < dp[a]) {
                    dp[a] = dp[a - d] + 1;
                    lastCoin[a] = d;
                }
            }
        }
        if (dp[amount] == Integer.MAX_VALUE) throw new IllegalStateException("no exact change possible");
        List<Integer> used = new ArrayList<Integer>();
        int a = amount;
        while (a > 0) { used.add(lastCoin[a]); a -= lastCoin[a]; }
        return used;
    }

    public static void main(String[] args) {
        int[] canonical = {25, 10, 5, 1};
        List<Integer> g1 = greedy(41, canonical);
        List<Integer> d1 = dpMinCoins(41, canonical);
        System.out.println("-- Canonical system: US cents {25,10,5,1}, amount=41 --");
        System.out.println("greedy: " + g1 + " (" + g1.size() + " coins)");
        System.out.println("dp:     " + d1 + " (" + d1.size() + " coins) -- same answer, greedy is optimal here");

        System.out.println();
        int[] nonCanonicalDesc = {4, 3, 1};
        List<Integer> g2 = greedy(6, nonCanonicalDesc);
        List<Integer> d2 = dpMinCoins(6, nonCanonicalDesc);
        System.out.println("-- Non-canonical system: {1,3,4}, amount=6 --");
        System.out.println("greedy: " + g2 + " (" + g2.size() + " coins) -- SUBOPTIMAL");
        System.out.println("dp:     " + d2 + " (" + d2.size() + " coins) -- optimal");
    }
}

Actual output, java ChangeAlgorithms:

-- Canonical system: US cents {25,10,5,1}, amount=41 --
greedy: [25, 10, 5, 1] (4 coins)
dp:     [25, 10, 5, 1] (4 coins) -- same answer, greedy is optimal here

-- Non-canonical system: {1,3,4}, amount=6 --
greedy: [4, 1, 1] (3 coins) -- SUBOPTIMAL
dp:     [3, 3] (2 coins) -- optimal

Same comparison, Go (runChangeAlgorithmsDemo, called from demo.go's main() in Movement 5):

// changealgo.go
package main

import "fmt"

// greedyChange picks the largest denomination first. Returns (nil, false)
// if it can't make exact change -- the gap naive implementations skip.
func greedyChange(amount int, denomsDesc []int) ([]int, bool) {
	var used []int
	remaining := amount
	for _, d := range denomsDesc {
		for remaining >= d {
			used = append(used, d)
			remaining -= d
		}
	}
	return used, remaining == 0
}

// dpMinCoins is the minimum-coins DP: guarantees the optimal count for ANY
// coin system -- canonical or not -- unlike greedy.
func dpMinCoins(amount int, denoms []int) ([]int, bool) {
	const inf = 1 << 30
	dp := make([]int, amount+1)
	last := make([]int, amount+1)
	for i := 1; i <= amount; i++ {
		dp[i] = inf
	}
	for a := 1; a <= amount; a++ {
		for _, d := range denoms {
			if d <= a && dp[a-d] != inf && dp[a-d]+1 < dp[a] {
				dp[a] = dp[a-d] + 1
				last[a] = d
			}
		}
	}
	if dp[amount] == inf {
		return nil, false
	}
	var used []int
	for a := amount; a > 0; {
		used = append(used, last[a])
		a -= last[a]
	}
	return used, true
}

func runChangeAlgorithmsDemo() {
	fmt.Println("-- Canonical system: US cents {25,10,5,1}, amount=41 --")
	g, _ := greedyChange(41, []int{25, 10, 5, 1})
	d, _ := dpMinCoins(41, []int{25, 10, 5, 1})
	fmt.Printf("greedy: %v (%d coins)\n", g, len(g))
	fmt.Printf("dp:     %v (%d coins) -- same answer, greedy is optimal here\n", d, len(d))

	fmt.Println()
	fmt.Println("-- Non-canonical system: {1,3,4}, amount=6 --")
	g2, _ := greedyChange(6, []int{4, 3, 1})
	d2, _ := dpMinCoins(6, []int{4, 3, 1})
	fmt.Printf("greedy: %v (%d coins) -- SUBOPTIMAL\n", g2, len(g2))
	fmt.Printf("dp:     %v (%d coins) -- optimal\n", d2, len(d2))
}

Output is identical in shape to the Java run (Go prints slices with spaces, not commas): greedy: [25 10 5 1] (4 coins) / dp: [25 10 5 1] (4 coins) for the canonical case, and greedy: [4 1 1] (3 coins) -- SUBOPTIMAL / dp: [3 3] (2 coins) -- optimal for {1,3,4} — same numbers, same conclusion, verified with go run . before publishing.

Greedy grabs the 4 first (largest fit), leaving 2, which only decomposes as two 1-coins — three coins total. The optimal answer, 3+3, is invisible to a greedy scan because greedy never reconsiders a choice once made. The minimum coin change DP tries every denomination at every amount and keeps the best, which costs O(amount × denominations) time and space instead of greedy's O(denominations) — a real cost, paid only where it's needed: vending machines the world over almost universally run on canonical currencies, so shipping greedy in production and reserving DP for the interview whiteboard (or for a currency you can't vouch for) is the correct, not lazy, choice.

Strategy for payment — a sketch, not part of the compiled core (this lab pays in coins only; card/note support is the natural M5 extension): a PaymentMethod interface with int amountTenderedCents(), implemented by CoinPayment, CardPayment, NotePayment. HasMoneyState.insertCoin-equivalent would call payment.amountTenderedCents() and add it to the balance, never caring which concrete payment type it was — exactly the same Strategy shape as the Strategy Pattern and the parking lot's pluggable FareStrategy. This is the answer to Movement 7's "how do you add a new payment type" — the states never change; only a new class implementing PaymentMethod is added.

7. Defend under drilling

An interviewer will push on the design. These are the follow-ups that come up almost every time, with the answer a staff engineer gives — short, concrete, no hedging.

8. You can now defend


Re-authored/Deepened for this guide. Reference code compiled and executed (javac -Xlint:all + java; gofmt, go vet, go build, go run) before publishing; both break-it bugs reproduce deterministically on every run, in both languages. See also: the State Pattern concept page, the Strategy Pattern, and Minimum Coin Change (DP).

🤖 Don't fully get this? Learn it with Claude

Stuck on Design a Vending Machine? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **Design a Vending Machine** (Hands-On Builds) and want to truly understand it. Explain Design a Vending Machine from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **Design a Vending Machine** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **Design a Vending Machine** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **Design a Vending Machine** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes