Knowledge Guide
HomeHands-On BuildsLLD Katas

Design an ATM

Design an ATM

The ATM is the LLD interview question that looks like a formality — "obviously" a card, a PIN, some cash — right up until two things happen: the interviewer asks what stops you from calling dispense() before enterPin() ever ran, and then asks what happens if the cash mechanism jams a half-second after the account has already been debited. The first is a State Pattern problem you can solve cleanly. The second is not a modeling problem at all — it's a distributed-systems problem hiding inside a single process: two independent operations (debit a ledger, dispense physical cash) that can each fail on their own, and a customer standing at the machine who cannot be left holding neither. This lab has you build an ATM in Java and Go, break the naive version on purpose — both the illegal-transition bug and the money-vanishes-on-a-jam bug — and fix both for real. See also the existing Design an ATM walkthrough for the class-diagram framing; do this lab first and that page will read like a victory lap.

1. The trap

You're asked to design an ATM. Cash, PIN, dispense — it feels simple enough that the fastest path is to wire a couple of booleans onto one class and start writing methods:

// Abridged trap sketch -- illustrative only, not the full reference
// (the full compiled version, with both bugs live, is in Movement 5)
class NaiveATM {
    boolean cardInserted = false;
    boolean authenticated = false;
    long balanceCents = 50000;

    void insertCard() { cardInserted = true; }

    void enterPin(int pin) {
        if (pin == 4321) authenticated = true;   // ...forgot to check cardInserted first
    }

    long dispense(long amountCents) {
        balanceCents -= amountCents;               // ...forgot to check authenticated at all
        return amountCents;                          // hands out cash regardless of PIN
    }
}

This compiles, and the happy path demos fine: insert card, enter PIN, dispense, done. But nothing in this code stops a caller from jumping straight to dispense(20000) on a machine that was just powered on, or from calling it twice before a second PIN check, or from calling dispense while authenticated is still false because enterPin never actually verified a card was present first. Every method independently trusts that every OTHER method was called correctly and in the right order — there is no code anywhere that says "you cannot dispense unless you are authenticated." There's only a hope that the UI layer enforces that order, and that hope breaks the moment a second entry point exists — a technician's diagnostic console, a "remote unlock" mobile flow — that calls dispense() without walking through the same UI screens a customer would.

And even once that's fixed with real state modeling, a second, sharper trap is still sitting underneath it — one that has nothing to do with authentication order. A customer requests $130. The account gets debited. Then the cash mechanism jams: a note misfeeds, a sensor faults, the drawer motor stalls. The debit already happened. The customer is now $130 poorer with nothing in their hand. That is not a hypothetical edge case invented for an interview — it is the single most consequential bug an ATM can ship, because unlike the illegal-transition bug above, it doesn't fail loudly in a demo. It fails silently, in production, one jam at a time, and it fails in the one direction a bank cannot tolerate: money debited, nothing dispensed, no record of why. Movement 5 reproduces both bugs, live, with real numbers.

2. Scope it like a senior

Before writing a line, pin the contract down. These are the questions worth asking out loud:

Answer for this kata: card + PIN, single demo account per ATM instance, withdrawal only (balance/deposit as extensions), denominations with limited counts and the exact-match edge, 3 PIN attempts then eject, cancel legal until the machine commits to dispensing, one lock scoped to the ATM's own state machine plus a separate lock scoped to the account's ledger, and crash recovery named as an extension rather than built.

3. Reason to the design

Attempt 0 — the trap, in full. A couple of booleans (cardInserted, authenticated) plus a balance, with each method independently re-deriving what it's supposed to check. We just watched this fail in Movement 1: not because anyone was careless, but because "is this call legal right now" is a question answered separately in every method, with no single place that owns the answer.

Attempt 1 — centralize the legality table. Replace the booleans with one Status enum (IDLE / CARD_INSERTED / PIN_AUTHENTICATED / SELECTING_TRANSACTION / DISPENSING) and a single lookup of "legal events per status," checked at the top of every method. Real improvement — it turns "did I forget a check" into "is the table right," auditable in one place. But the logic behind each event — validate the PIN, compute the note plan, run the withdrawal — still lives in the same if/else ladder underneath, just guarded by a table lookup instead of ad-hoc booleans. You've deduplicated the guard, not the mess behind it.

Attempt 2 — give each state its own object. Make each state a class implementing one shared interface (insertCard / enterPin / selectWithdrawal / confirm / cancel), and only write real logic for the events legal FROM that state. Every other method is a one-line rejection — true by construction, not by a check someone remembered to write: DispensingState.insertCard() doesn't contain a guard against being called mid-transaction, because there is no other situation in which DispensingState's code runs at all — you are only ever executing it while a withdrawal is genuinely in flight. The ATM (the context) holds whichever state object is current and forwards every customer action to it; nothing outside the five state classes needs to know the full legality table — 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's no line anywhere that reads "if state is IDLE, reject enterPin" — IdleState.enterPin() simply never contains authentication logic. Add a sixth event later (say, changePin) and the compiler forces every one of the five state classes to answer for it — you cannot silently forget one, which is precisely the failure mode Movement 1's naive sketch fell into.

Now the second trap — THE crux of this kata. A perfectly modeled state machine still doesn't save you from the atomicity bug, because the bug isn't about which methods are callable — it's about what happens INSIDE the one method that's legitimately allowed to run. confirm() in the SelectingTransaction state legitimately needs to (a) debit the account and (b) physically dispense cash. Those are two separate operations against two separate systems — a database row and a piece of hardware — and either one can fail independently of the other. Debit-then-dispense (the naive order) leaves exactly one failure mode uncovered: debit succeeds, dispense fails. There is no code path in "debit, then dispense, return whatever dispense returned" that puts the money back once the debit has already committed.

The fix — separate "earmark" from "commit." Split the debit into two steps against the account itself: reserve (earmark the funds — a hold, not a debit, so a concurrent withdrawal against the same account can't also claim that money) — then attempt the physical dispense — then either commit (turn the hold into a real debit, but ONLY once cash is confirmed dispensed) or release (drop the hold, no debit, if the dispense failed). At every point in that sequence, exactly one of three things is true: nothing happened yet; funds are held but not yet debited (if the machine died right here, the hold can be safely released or reconciled on restart — nothing was ever taken); or both the debit and the dispense completed. The state that must never be observable — debited with no cash out — is now unreachable by construction, because commit() is only ever called from the branch where dispense() already returned success. This is the same shape as a two-phase commit or a saga's compensating transaction, just scoped to two local objects instead of two networked services — worth naming, because Movement 7 asks you to scale this exact idea past one machine.

4. Build it — milestones

Build the ATM with this contract, then grow it milestone by milestone. Attempt each yourself before reading the reference implementation.

Reference implementation — Java (State pattern + reserve/commit-or-compensate withdrawal)

Package atm, one class per file. The State classes are singletons (no per-instance data — all mutable state lives in the ATM context, the same convention the vending-machine kata uses). Account and CashDispenser each hold their own lock, independent of the ATM's own synchronized methods — three separate locks for three separate invariants, on purpose (Movement 7 explains why that separation matters).

ATMState.java:

package atm;

/** One method per customer action. Each concrete state implements ALL FIVE --
 *  the ones illegal from that state simply return a REJECTED string; there
 *  is no separate guard anywhere re-deriving "am I allowed to do this,"
 *  because the state you're standing in IS the answer. Cancel/eject is
 *  legal from every state except Dispensing (cash is physically committed
 *  and moving -- the same physical constraint a vending machine's motor has). */
public interface ATMState {
    String insertCard(ATM atm, Card card);
    String enterPin(ATM atm, int pin);
    String selectWithdrawal(ATM atm, long amountCents);
    String confirm(ATM atm);
    String cancel(ATM atm);
}

Card.java:

package atm;

public final class Card {
    public final String number;
    public Card(String number) { this.number = number; }
}

Account.java:

package atm;

import java.util.concurrent.locks.ReentrantLock;

/** The bank account side of a withdrawal. `heldCents` is money earmarked by an
 *  in-flight withdrawal but not yet actually debited -- see WithdrawalService
 *  for why that distinction is the whole fix for the atomicity bug. This lock
 *  is independent of the ATM's own lock: it protects the ledger from ANY
 *  caller (this ATM, another ATM, the mobile app), not just this one machine. */
public final class Account {
    private final ReentrantLock lock = new ReentrantLock();
    private long balanceCents;
    private long heldCents;

    public Account(long openingBalanceCents) {
        this.balanceCents = openingBalanceCents;
    }

    public long availableCents() {
        lock.lock();
        try { return balanceCents - heldCents; } finally { lock.unlock(); }
    }

    public long balanceCents() {
        lock.lock();
        try { return balanceCents; } finally { lock.unlock(); }
    }

    /** Earmark funds for an in-flight withdrawal WITHOUT touching the balance yet. */
    boolean reserve(long amountCents) {
        lock.lock();
        try {
            if (balanceCents - heldCents < amountCents) return false;
            heldCents += amountCents;
            return true;
        } finally { lock.unlock(); }
    }

    /** Cash physically left the machine -- turn the hold into a real debit. */
    void commit(long amountCents) {
        lock.lock();
        try {
            heldCents -= amountCents;
            balanceCents -= amountCents;
        } finally { lock.unlock(); }
    }

    /** Dispense failed -- drop the hold. No money ever left the account. */
    void release(long amountCents) {
        lock.lock();
        try { heldCents -= amountCents; } finally { lock.unlock(); }
    }

    /** NAIVE PATH ONLY (Movement 1/5's trap) -- debits immediately, with no
     *  hold and no way back. WithdrawalService never calls this. */
    void debitDirectly(long amountCents) {
        lock.lock();
        try { balanceCents -= amountCents; } finally { lock.unlock(); }
    }
}

CashDispenser.java:

package atm;

import java.util.Arrays;

/** Physical note stock, keyed by denomination, largest first. Greedy
 *  allocation: take as many of the largest denomination as fit, then the
 *  next, with no backtracking. See Movement 6 for exactly when that fails. */
public final class CashDispenser {
    private final long[] denominationsCentsDesc;
    private final int[] counts; // parallel to denominationsCentsDesc
    private boolean forceJam = false; // test hook: fail the NEXT dispense() call, notes untouched

    public CashDispenser(long[] denominationsCentsDesc, int[] counts) {
        this.denominationsCentsDesc = denominationsCentsDesc;
        this.counts = counts;
    }

    /** Test-only hook simulating a hardware jam on the next dispense attempt. */
    public synchronized void forceJamOnNextDispense() { forceJam = true; }

    public synchronized int[] noteCounts() { return Arrays.copyOf(counts, counts.length); }

    /** Greedy plan: largest denomination first, capped by what's on hand.
     *  Returns null if the remainder can't be brought to exactly zero --
     *  greedy never backtracks to try a different split, so it can reject
     *  amounts a smarter search would still be able to make (Movement 6). */
    public synchronized int[] planGreedy(long amountCents) {
        int[] plan = new int[denominationsCentsDesc.length];
        long remaining = amountCents;
        for (int i = 0; i < denominationsCentsDesc.length; i++) {
            long denom = denominationsCentsDesc[i];
            long take = Math.min(counts[i], remaining / denom);
            plan[i] = (int) take;
            remaining -= take * denom;
        }
        return remaining == 0 ? plan : null;
    }

    /** Physically dispense a precomputed plan. Notes are only decremented on
     *  success -- a forced/simulated jam leaves the drawer untouched. */
    public synchronized boolean dispense(int[] plan) {
        if (forceJam) { forceJam = false; return false; }
        for (int i = 0; i < plan.length; i++) counts[i] -= plan[i];
        return true;
    }
}

WithdrawalResult.java:

package atm;

/** Outcome of one withdrawal attempt: success flag, a human-readable detail,
 *  and (on success) the exact notes dispensed, largest denomination first. */
public final class WithdrawalResult {
    public final boolean success;
    public final String detail;
    public final int[] notesDispensed; // parallel to CashDispenser's denomination order; null on failure

    WithdrawalResult(boolean success, String detail, int[] notesDispensed) {
        this.success = success;
        this.detail = detail;
        this.notesDispensed = notesDispensed;
    }
}

WithdrawalService.java:

package atm;

import java.util.Arrays;

/** THE crux of the kata: debiting the account and dispensing physical cash
 *  are two separate operations that can each fail independently. Reserve the
 *  funds first (earmark, no debit yet), attempt the physical dispense, and
 *  only THEN either commit the debit (cash is in the customer's hand) or
 *  release the hold (dispense failed -- customer keeps their money, nothing
 *  was ever removed). */
public final class WithdrawalService {
    private WithdrawalService() {}

    public static WithdrawalResult withdraw(Account account, CashDispenser dispenser, long amountCents) {
        if (amountCents <= 0) {
            return new WithdrawalResult(false, "invalid amount " + amountCents + "c", null);
        }
        int[] plan = dispenser.planGreedy(amountCents);
        if (plan == null) {
            // Reject BEFORE touching the account at all -- no hold, no debit, nothing to undo.
            return new WithdrawalResult(false, "cannot make " + amountCents + "c exact change with available notes", null);
        }
        if (!account.reserve(amountCents)) {
            return new WithdrawalResult(false, "insufficient funds (available=" + account.availableCents() + "c)", null);
        }
        boolean dispensed = dispenser.dispense(plan);
        if (dispensed) {
            account.commit(amountCents); // cash is out -- NOW make the debit real
            return new WithdrawalResult(true, amountCents + "c dispensed as " + Arrays.toString(plan), plan);
        }
        account.release(amountCents); // jam -- release the hold, no debit ever happened
        return new WithdrawalResult(false, "dispenser jam -- hold released, account NOT debited", null);
    }
}

NaiveWithdrawal.java:

package atm;

/** Movement 1's trap, in code: debit first, dispense second, hope for the
 *  best. If the dispense fails AFTER the debit succeeds, the customer's
 *  money is gone and no cash came out -- the bug Movement 5 reproduces. */
public final class NaiveWithdrawal {
    private NaiveWithdrawal() {}

    public static boolean withdraw(Account account, CashDispenser dispenser, long amountCents) {
        if (account.availableCents() < amountCents) return false;
        account.debitDirectly(amountCents);        // DEBIT FIRST -- the bug
        int[] plan = dispenser.planGreedy(amountCents);
        if (plan == null) return false;              // money is ALREADY gone; nothing dispensed at all
        return dispenser.dispense(plan);              // if this returns false (jam), money is STILL gone
    }
}

IdleState.java:

package atm;

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

    public String insertCard(ATM atm, Card card) {
        atm.setCard(card);
        atm.setState(CardInsertedState.INSTANCE);
        return "card inserted -> CARD_INSERTED";
    }
    public String enterPin(ATM atm, int pin) {
        return "REJECTED: insert a card before entering a PIN (state=IDLE)";
    }
    public String selectWithdrawal(ATM atm, long amountCents) {
        return "REJECTED: insert a card and authenticate first (state=IDLE)";
    }
    public String confirm(ATM atm) {
        return "REJECTED: nothing to confirm (state=IDLE)";
    }
    public String cancel(ATM atm) {
        return "nothing to cancel, already IDLE";
    }
}

CardInsertedState.java:

package atm;

public final class CardInsertedState implements ATMState {
    static final CardInsertedState INSTANCE = new CardInsertedState();
    private CardInsertedState() {}

    public String insertCard(ATM atm, Card card) {
        return "REJECTED: a card is already inserted (state=CARD_INSERTED)";
    }
    public String enterPin(ATM atm, int pin) {
        if (atm.checkPin(pin)) {
            atm.resetPinAttempts();
            atm.setState(PinAuthenticatedState.INSTANCE);
            return "PIN correct -> PIN_AUTHENTICATED";
        }
        atm.incrementPinAttempts();
        if (atm.pinAttempts() >= 3) {
            atm.ejectCard();
            atm.setState(IdleState.INSTANCE);
            return "REJECTED: incorrect PIN, 3rd attempt -- card ejected -> IDLE";
        }
        return "REJECTED: incorrect PIN (" + atm.pinAttempts() + "/3 attempts)";
    }
    public String selectWithdrawal(ATM atm, long amountCents) {
        return "REJECTED: enter your PIN before selecting a transaction (state=CARD_INSERTED)";
    }
    public String confirm(ATM atm) {
        return "REJECTED: nothing to confirm (state=CARD_INSERTED)";
    }
    public String cancel(ATM atm) {
        atm.ejectCard();
        atm.setState(IdleState.INSTANCE);
        return "cancelled, card ejected -> IDLE";
    }
}

PinAuthenticatedState.java:

package atm;

public final class PinAuthenticatedState implements ATMState {
    static final PinAuthenticatedState INSTANCE = new PinAuthenticatedState();
    private PinAuthenticatedState() {}

    public String insertCard(ATM atm, Card card) {
        return "REJECTED: a card is already inserted and authenticated (state=PIN_AUTHENTICATED)";
    }
    public String enterPin(ATM atm, int pin) {
        return "REJECTED: already authenticated (state=PIN_AUTHENTICATED)";
    }
    public String selectWithdrawal(ATM atm, long amountCents) {
        if (amountCents <= 0) return "REJECTED: invalid amount " + amountCents + "c";
        atm.setPendingAmount(amountCents);
        atm.setState(SelectingTransactionState.INSTANCE);
        return "withdrawal of " + amountCents + "c selected -> SELECTING_TRANSACTION (call confirm())";
    }
    public String confirm(ATM atm) {
        return "REJECTED: select a transaction first (state=PIN_AUTHENTICATED)";
    }
    public String cancel(ATM atm) {
        atm.ejectCard();
        atm.setState(IdleState.INSTANCE);
        return "cancelled, card ejected -> IDLE";
    }
}

SelectingTransactionState.java:

package atm;

public final class SelectingTransactionState implements ATMState {
    static final SelectingTransactionState INSTANCE = new SelectingTransactionState();
    private SelectingTransactionState() {}

    public String insertCard(ATM atm, Card card) {
        return "REJECTED: a transaction is already pending (state=SELECTING_TRANSACTION)";
    }
    public String enterPin(ATM atm, int pin) {
        return "REJECTED: already authenticated (state=SELECTING_TRANSACTION)";
    }
    public String selectWithdrawal(ATM atm, long amountCents) {
        return "REJECTED: a withdrawal is already pending -- confirm or cancel it first (state=SELECTING_TRANSACTION)";
    }
    public String confirm(ATM atm) {
        // Commit to the DISPENSING branch BEFORE doing the work -- no other
        // customer action can interleave once we're here (ATM.confirm() is
        // synchronized on the ATM itself).
        atm.setState(DispensingState.INSTANCE);
        long amount = atm.pendingAmount();
        WithdrawalResult result = WithdrawalService.withdraw(atm.account(), atm.dispenser(), amount);
        atm.ejectCard();
        atm.setState(IdleState.INSTANCE);
        return result.success
            ? "DISPENSED " + result.detail + " -> IDLE (card ejected)"
            : "ABORTED: " + result.detail + " -> IDLE (card ejected, account untouched or hold released)";
    }
    public String cancel(ATM atm) {
        atm.ejectCard();
        atm.setState(IdleState.INSTANCE);
        return "cancelled before confirming, card ejected -> IDLE (nothing was ever reserved)";
    }
}

DispensingState.java:

package atm;

/** Reached only for the instant WithdrawalService actually runs
 *  (SelectingTransactionState.confirm() sets this state, does the work, then
 *  leaves it). Rejects every event -- cash is physically committed to
 *  moving; there is no legal way to insert a card, retry a PIN, pick a
 *  different amount, or cancel from here. */
public final class DispensingState implements ATMState {
    static final DispensingState INSTANCE = new DispensingState();
    private DispensingState() {}

    public String insertCard(ATM atm, Card card) {
        return "REJECTED: transaction in progress (state=DISPENSING)";
    }
    public String enterPin(ATM atm, int pin) {
        return "REJECTED: transaction in progress (state=DISPENSING)";
    }
    public String selectWithdrawal(ATM atm, long amountCents) {
        return "REJECTED: transaction in progress (state=DISPENSING)";
    }
    public String confirm(ATM atm) {
        return "REJECTED: already dispensing (state=DISPENSING)";
    }
    public String cancel(ATM atm) {
        return "REJECTED: cannot cancel mid-dispense, already committed (state=DISPENSING)";
    }
}

ATM.java:

package atm;

/** The context: holds the current state and forwards every customer action
 *  to it. `synchronized` models the single physical machine -- only one
 *  event (card/PIN/select/confirm/cancel) is ever in flight, exactly like
 *  one card slot and one keypad. This lock is SEPARATE from Account's own
 *  lock: this one serializes this machine's own events; Account's protects
 *  the shared ledger from every caller, including a second ATM or the
 *  mobile app touching the same account concurrently. */
public final class ATM {
    private ATMState state = IdleState.INSTANCE;
    private Card insertedCard;
    private int pinAttempts = 0;
    private long pendingAmountCents;
    private final Account account;
    private final CashDispenser dispenser;
    private static final int CORRECT_PIN = 4321; // toy: one demo account/PIN pair

    public ATM(Account account, CashDispenser dispenser) {
        this.account = account;
        this.dispenser = dispenser;
    }

    public synchronized String insertCard(Card c) { return state.insertCard(this, c); }
    public synchronized String enterPin(int pin) { return state.enterPin(this, pin); }
    public synchronized String selectWithdrawal(long amountCents) { return state.selectWithdrawal(this, amountCents); }
    public synchronized String confirm() { return state.confirm(this); }
    public synchronized String cancel() { return state.cancel(this); }

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

    // -- package-private hooks used only by ATMState implementations --
    void setState(ATMState s) { this.state = s; }
    void setCard(Card c) { this.insertedCard = c; }
    void ejectCard() { this.insertedCard = null; }
    boolean checkPin(int pin) { return insertedCard != null && pin == CORRECT_PIN; }
    void incrementPinAttempts() { pinAttempts++; }
    void resetPinAttempts() { pinAttempts = 0; }
    int pinAttempts() { return pinAttempts; }
    void setPendingAmount(long amountCents) { this.pendingAmountCents = amountCents; }
    long pendingAmount() { return pendingAmountCents; }
    Account account() { return account; }
    CashDispenser dispenser() { return dispenser; }
}

StateMachineDemo.java (default package) walks M1–M5 end to end:

import atm.ATM;
import atm.Account;
import atm.Card;
import atm.CashDispenser;
import java.util.Arrays;

/** Walks the legal happy paths end to end against the State-pattern ATM. */
public final class StateMachineDemo {
    public static void main(String[] args) {
        Account account = new Account(50000);           // $500.00 opening balance
        CashDispenser dispenser = new CashDispenser(
            new long[]{5000, 2000, 1000},                 // $50, $20, $10 notes
            new int[]{4, 4, 4});                            // 4 of each
        ATM machine = new ATM(account, dispenser);

        System.out.println("-- M1: insert card, authenticate --");
        System.out.println(machine.insertCard(new Card("4111-...-0001")));
        System.out.println(machine.enterPin(9999));         // wrong PIN, attempt 1/3
        System.out.println(machine.enterPin(4321));         // correct PIN

        System.out.println();
        System.out.println("-- M2/M3: select + confirm a withdrawal, greedy notes --");
        System.out.println(machine.selectWithdrawal(13000)); // $130.00
        System.out.println(machine.confirm());
        System.out.println("balance after = " + account.balanceCents() + "c");
        System.out.println("notes remaining = " + Arrays.toString(dispenser.noteCounts()));

        System.out.println();
        System.out.println("-- insufficient funds (dedicated small account, notes are NOT the constraint) --");
        Account smallAccount = new Account(3000);          // $30.00 balance only
        CashDispenser ampleDispenser = new CashDispenser(
            new long[]{5000, 2000, 1000}, new int[]{10, 10, 10}); // plenty of notes on hand
        ATM smallMachine = new ATM(smallAccount, ampleDispenser);
        System.out.println(smallMachine.insertCard(new Card("4111-...-0003")));
        System.out.println(smallMachine.enterPin(4321));
        System.out.println(smallMachine.selectWithdrawal(10000)); // $100 -- notes CAN make this, balance can't cover it
        System.out.println(smallMachine.confirm());

        System.out.println();
        System.out.println("-- M4: cancel mid-flow, before confirming --");
        System.out.println(machine.insertCard(new Card("4111-...-0001")));
        System.out.println(machine.enterPin(4321));
        System.out.println(machine.selectWithdrawal(2000));
        System.out.println(machine.cancel());
        System.out.println("balance unchanged (no additional debit) = " + account.balanceCents() + "c");

        System.out.println();
        System.out.println("-- 3 wrong PINs -- card ejected --");
        System.out.println(machine.insertCard(new Card("4111-...-0002")));
        System.out.println(machine.enterPin(1111));
        System.out.println(machine.enterPin(2222));
        System.out.println(machine.enterPin(3333));
        System.out.println("state = " + machine.currentState());
    }
}

Actual captured output from java StateMachineDemo:

-- M1: insert card, authenticate --
card inserted -> CARD_INSERTED
REJECTED: incorrect PIN (1/3 attempts)
PIN correct -> PIN_AUTHENTICATED

-- M2/M3: select + confirm a withdrawal, greedy notes --
withdrawal of 13000c selected -> SELECTING_TRANSACTION (call confirm())
DISPENSED 13000c dispensed as [2, 1, 1] -> IDLE (card ejected)
balance after = 37000c
notes remaining = [2, 3, 3]

-- insufficient funds (dedicated small account, notes are NOT the constraint) --
card inserted -> CARD_INSERTED
PIN correct -> PIN_AUTHENTICATED
withdrawal of 10000c selected -> SELECTING_TRANSACTION (call confirm())
ABORTED: insufficient funds (available=3000c) -> IDLE (card ejected, account untouched or hold released)

-- M4: cancel mid-flow, before confirming --
card inserted -> CARD_INSERTED
PIN correct -> PIN_AUTHENTICATED
withdrawal of 2000c selected -> SELECTING_TRANSACTION (call confirm())
cancelled before confirming, card ejected -> IDLE (nothing was ever reserved)
balance unchanged (no additional debit) = 37000c

-- 3 wrong PINs -- card ejected --
card inserted -> CARD_INSERTED
REJECTED: incorrect PIN (1/3 attempts)
REJECTED: incorrect PIN (2/3 attempts)
REJECTED: incorrect PIN, 3rd attempt -- card ejected -> IDLE
state = IdleState

Compiled clean with javac -Xlint:all -Werror (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, spelled the Go way. All files share package main so the module builds as one binary.

state.go:

package main

// ATMState is the State interface: one method per customer action. Every
// concrete state implements all five -- illegal ones just return a REJECTED
// string, because the state you're in IS the legality check; there's no
// separate guard re-deriving it anywhere else. Cancel/eject is legal from
// every state except Dispensing (cash is physically committed and moving).
type ATMState interface {
	InsertCard(atm *ATM, card *Card) string
	EnterPIN(atm *ATM, pin int) string
	SelectWithdrawal(atm *ATM, amountCents int64) string
	Confirm(atm *ATM) string
	Cancel(atm *ATM) string
}

card.go:

package main

// Card is the customer's inserted card.
type Card struct {
	Number string
}

account.go:

package main

import "sync"

// Account is the bank ledger side of a withdrawal. `held` is money earmarked
// by an in-flight withdrawal but not yet actually debited -- see
// WithdrawReserveCommit for why that split is the whole fix for the
// atomicity bug. This lock is independent of the ATM's own lock: it protects
// the ledger from ANY caller (this ATM, another ATM, the mobile app), not
// just this one machine.
type Account struct {
	mu      sync.Mutex
	balance int64
	held    int64
}

func NewAccount(openingBalanceCents int64) *Account {
	return &Account{balance: openingBalanceCents}
}

func (a *Account) Available() int64 {
	a.mu.Lock()
	defer a.mu.Unlock()
	return a.balance - a.held
}

func (a *Account) Balance() int64 {
	a.mu.Lock()
	defer a.mu.Unlock()
	return a.balance
}

// Reserve earmarks funds without touching the balance yet.
func (a *Account) Reserve(amountCents int64) bool {
	a.mu.Lock()
	defer a.mu.Unlock()
	if a.balance-a.held < amountCents {
		return false
	}
	a.held += amountCents
	return true
}

// Commit turns a hold into a real debit -- call only after cash is confirmed dispensed.
func (a *Account) Commit(amountCents int64) {
	a.mu.Lock()
	defer a.mu.Unlock()
	a.held -= amountCents
	a.balance -= amountCents
}

// Release drops the hold with no debit -- call when dispensing failed.
func (a *Account) Release(amountCents int64) {
	a.mu.Lock()
	defer a.mu.Unlock()
	a.held -= amountCents
}

// DebitDirectly is the NAIVE path (Movement 1/5's trap) -- debits
// immediately, no hold, no way back. WithdrawReserveCommit never calls this.
func (a *Account) DebitDirectly(amountCents int64) {
	a.mu.Lock()
	defer a.mu.Unlock()
	a.balance -= amountCents
}

dispenser.go:

package main

import "sync"

// CashDispenser holds physical note stock, largest denomination first.
// Greedy allocation: take as many of the largest denomination as fit, then
// the next, with no backtracking -- see Movement 6 for exactly when that fails.
type CashDispenser struct {
	mu       sync.Mutex
	denoms   []int64 // cents, descending
	counts   []int   // parallel to denoms
	forceJam bool    // test hook: fail the NEXT Dispense call, notes untouched
}

func NewCashDispenser(denomsDesc []int64, counts []int) *CashDispenser {
	return &CashDispenser{denoms: denomsDesc, counts: counts}
}

// ForceJamOnNextDispense simulates a hardware jam on the next Dispense call.
func (d *CashDispenser) ForceJamOnNextDispense() {
	d.mu.Lock()
	defer d.mu.Unlock()
	d.forceJam = true
}

func (d *CashDispenser) NoteCounts() []int {
	d.mu.Lock()
	defer d.mu.Unlock()
	out := make([]int, len(d.counts))
	copy(out, d.counts)
	return out
}

// PlanGreedy picks the largest denomination first, capped by what's on hand.
// Returns nil if the remainder can't be brought to exactly zero -- greedy
// never backtracks, so it can reject an amount a smarter search could still make.
func (d *CashDispenser) PlanGreedy(amountCents int64) []int {
	d.mu.Lock()
	defer d.mu.Unlock()
	plan := make([]int, len(d.denoms))
	remaining := amountCents
	for i, denom := range d.denoms {
		take := remaining / denom
		if take > int64(d.counts[i]) {
			take = int64(d.counts[i])
		}
		plan[i] = int(take)
		remaining -= take * denom
	}
	if remaining != 0 {
		return nil
	}
	return plan
}

// Dispense physically dispenses a precomputed plan. Notes are only
// decremented on success -- a simulated jam leaves the drawer untouched.
func (d *CashDispenser) Dispense(plan []int) bool {
	d.mu.Lock()
	defer d.mu.Unlock()
	if d.forceJam {
		d.forceJam = false
		return false
	}
	for i, n := range plan {
		d.counts[i] -= n
	}
	return true
}

withdrawal.go:

package main

import "fmt"

// WithdrawalResult is the outcome of one withdrawal attempt.
type WithdrawalResult struct {
	Success bool
	Detail  string
	Notes   []int // nil on failure
}

// WithdrawReserveCommit is THE crux of the kata: debiting the account and
// dispensing physical cash are two separate operations that can each fail
// independently. Reserve first (earmark, no debit), attempt the physical
// dispense, and only then either commit the debit (cash is in the
// customer's hand) or release the hold (dispense failed -- customer keeps
// their money, nothing was ever removed).
func WithdrawReserveCommit(account *Account, dispenser *CashDispenser, amountCents int64) WithdrawalResult {
	if amountCents <= 0 {
		return WithdrawalResult{false, fmt.Sprintf("invalid amount %dc", amountCents), nil}
	}
	plan := dispenser.PlanGreedy(amountCents)
	if plan == nil {
		// Reject BEFORE touching the account -- no hold, no debit, nothing to undo.
		return WithdrawalResult{false, fmt.Sprintf("cannot make %dc exact change with available notes", amountCents), nil}
	}
	if !account.Reserve(amountCents) {
		return WithdrawalResult{false, fmt.Sprintf("insufficient funds (available=%dc)", account.Available()), nil}
	}
	if dispenser.Dispense(plan) {
		account.Commit(amountCents) // cash is out -- NOW make the debit real
		return WithdrawalResult{true, fmt.Sprintf("%dc dispensed as %v", amountCents, plan), plan}
	}
	account.Release(amountCents) // jam -- release the hold, no debit ever happened
	return WithdrawalResult{false, "dispenser jam -- hold released, account NOT debited", nil}
}

naive.go:

package main

// WithdrawNaive mirrors Movement 1's trap: debit first, dispense second,
// hope for the best. If dispense fails AFTER the debit succeeds, the
// customer's money is gone and no cash came out -- Movement 5 reproduces this.
func WithdrawNaive(account *Account, dispenser *CashDispenser, amountCents int64) bool {
	if account.Available() < amountCents {
		return false
	}
	account.DebitDirectly(amountCents) // DEBIT FIRST -- the bug
	plan := dispenser.PlanGreedy(amountCents)
	if plan == nil {
		return false // money is ALREADY gone; nothing dispensed at all
	}
	return dispenser.Dispense(plan) // if false (jam), money is STILL gone
}

idle.go:

package main

type idleState struct{}

func (idleState) InsertCard(atm *ATM, card *Card) string {
	atm.card = card
	atm.state = cardInsertedState{}
	return "card inserted -> CARD_INSERTED"
}
func (idleState) EnterPIN(atm *ATM, pin int) string {
	return "REJECTED: insert a card before entering a PIN (state=IDLE)"
}
func (idleState) SelectWithdrawal(atm *ATM, amountCents int64) string {
	return "REJECTED: insert a card and authenticate first (state=IDLE)"
}
func (idleState) Confirm(atm *ATM) string {
	return "REJECTED: nothing to confirm (state=IDLE)"
}
func (idleState) Cancel(atm *ATM) string {
	return "nothing to cancel, already IDLE"
}

cardinserted.go:

package main

import "fmt"

type cardInsertedState struct{}

func (cardInsertedState) InsertCard(atm *ATM, card *Card) string {
	return "REJECTED: a card is already inserted (state=CARD_INSERTED)"
}
func (cardInsertedState) EnterPIN(atm *ATM, pin int) string {
	if atm.checkPIN(pin) {
		atm.pinAttempts = 0
		atm.state = pinAuthenticatedState{}
		return "PIN correct -> PIN_AUTHENTICATED"
	}
	atm.pinAttempts++
	if atm.pinAttempts >= 3 {
		atm.ejectCard()
		atm.state = idleState{}
		return "REJECTED: incorrect PIN, 3rd attempt -- card ejected -> IDLE"
	}
	return fmt.Sprintf("REJECTED: incorrect PIN (%d/3 attempts)", atm.pinAttempts)
}
func (cardInsertedState) SelectWithdrawal(atm *ATM, amountCents int64) string {
	return "REJECTED: enter your PIN before selecting a transaction (state=CARD_INSERTED)"
}
func (cardInsertedState) Confirm(atm *ATM) string {
	return "REJECTED: nothing to confirm (state=CARD_INSERTED)"
}
func (cardInsertedState) Cancel(atm *ATM) string {
	atm.ejectCard()
	atm.state = idleState{}
	return "cancelled, card ejected -> IDLE"
}

pinauth.go:

package main

type pinAuthenticatedState struct{}

func (pinAuthenticatedState) InsertCard(atm *ATM, card *Card) string {
	return "REJECTED: a card is already inserted and authenticated (state=PIN_AUTHENTICATED)"
}
func (pinAuthenticatedState) EnterPIN(atm *ATM, pin int) string {
	return "REJECTED: already authenticated (state=PIN_AUTHENTICATED)"
}
func (pinAuthenticatedState) SelectWithdrawal(atm *ATM, amountCents int64) string {
	if amountCents <= 0 {
		return "REJECTED: invalid amount"
	}
	atm.pendingAmount = amountCents
	atm.state = selectingTransactionState{}
	return "withdrawal selected -> SELECTING_TRANSACTION (call Confirm())"
}
func (pinAuthenticatedState) Confirm(atm *ATM) string {
	return "REJECTED: select a transaction first (state=PIN_AUTHENTICATED)"
}
func (pinAuthenticatedState) Cancel(atm *ATM) string {
	atm.ejectCard()
	atm.state = idleState{}
	return "cancelled, card ejected -> IDLE"
}

selecting.go:

package main

import "fmt"

type selectingTransactionState struct{}

func (selectingTransactionState) InsertCard(atm *ATM, card *Card) string {
	return "REJECTED: a transaction is already pending (state=SELECTING_TRANSACTION)"
}
func (selectingTransactionState) EnterPIN(atm *ATM, pin int) string {
	return "REJECTED: already authenticated (state=SELECTING_TRANSACTION)"
}
func (selectingTransactionState) SelectWithdrawal(atm *ATM, amountCents int64) string {
	return "REJECTED: a withdrawal is already pending -- confirm or cancel it first (state=SELECTING_TRANSACTION)"
}
func (selectingTransactionState) Confirm(atm *ATM) string {
	// Commit to DISPENSING before doing the work -- no other event can
	// interleave once we're here (ATM.Confirm holds the ATM's own mutex).
	atm.state = dispensingState{}
	amount := atm.pendingAmount
	result := WithdrawReserveCommit(atm.account, atm.dispenser, amount)
	atm.ejectCard()
	atm.state = idleState{}
	if result.Success {
		return fmt.Sprintf("DISPENSED %s -> IDLE (card ejected)", result.Detail)
	}
	return fmt.Sprintf("ABORTED: %s -> IDLE (card ejected, account untouched or hold released)", result.Detail)
}
func (selectingTransactionState) Cancel(atm *ATM) string {
	atm.ejectCard()
	atm.state = idleState{}
	return "cancelled before confirming, card ejected -> IDLE (nothing was ever reserved)"
}

dispensing.go:

package main

// dispensingState is reached only for the instant WithdrawReserveCommit
// actually runs. Rejects every event -- cash is physically committed to
// moving; there is no legal way to insert a card, retry a PIN, pick a
// different amount, or cancel from here.
type dispensingState struct{}

func (dispensingState) InsertCard(atm *ATM, card *Card) string {
	return "REJECTED: transaction in progress (state=DISPENSING)"
}
func (dispensingState) EnterPIN(atm *ATM, pin int) string {
	return "REJECTED: transaction in progress (state=DISPENSING)"
}
func (dispensingState) SelectWithdrawal(atm *ATM, amountCents int64) string {
	return "REJECTED: transaction in progress (state=DISPENSING)"
}
func (dispensingState) Confirm(atm *ATM) string {
	return "REJECTED: already dispensing (state=DISPENSING)"
}
func (dispensingState) Cancel(atm *ATM) string {
	return "REJECTED: cannot cancel mid-dispense, already committed (state=DISPENSING)"
}

atm.go:

package main

import "sync"

const correctPIN = 4321 // toy: one demo account/PIN pair

// ATM is the context: it holds the current state and forwards every
// customer action to it. mu models the single physical machine -- only one
// event is ever in flight, exactly like one card slot and one keypad. This
// lock is SEPARATE from Account's own lock: this one serializes this
// machine's own events; Account's protects the shared ledger from every
// caller, including a second ATM or the mobile app touching the same
// account concurrently.
type ATM struct {
	mu            sync.Mutex
	state         ATMState
	card          *Card
	pinAttempts   int
	pendingAmount int64
	account       *Account
	dispenser     *CashDispenser
}

func NewATM(account *Account, dispenser *CashDispenser) *ATM {
	return &ATM{state: idleState{}, account: account, dispenser: dispenser}
}

func (atm *ATM) InsertCard(c *Card) string {
	atm.mu.Lock()
	defer atm.mu.Unlock()
	return atm.state.InsertCard(atm, c)
}
func (atm *ATM) EnterPIN(pin int) string {
	atm.mu.Lock()
	defer atm.mu.Unlock()
	return atm.state.EnterPIN(atm, pin)
}
func (atm *ATM) SelectWithdrawal(amountCents int64) string {
	atm.mu.Lock()
	defer atm.mu.Unlock()
	return atm.state.SelectWithdrawal(atm, amountCents)
}
func (atm *ATM) Confirm() string {
	atm.mu.Lock()
	defer atm.mu.Unlock()
	return atm.state.Confirm(atm)
}
func (atm *ATM) Cancel() string {
	atm.mu.Lock()
	defer atm.mu.Unlock()
	return atm.state.Cancel(atm)
}

func (atm *ATM) CurrentState() string {
	switch atm.state.(type) {
	case idleState:
		return "IdleState"
	case cardInsertedState:
		return "CardInsertedState"
	case pinAuthenticatedState:
		return "PinAuthenticatedState"
	case selectingTransactionState:
		return "SelectingTransactionState"
	case dispensingState:
		return "DispensingState"
	default:
		return "unknown"
	}
}

// -- hooks used only by ATMState implementations --
func (atm *ATM) ejectCard()            { atm.card = nil }
func (atm *ATM) checkPIN(pin int) bool { return atm.card != nil && pin == correctPIN }

statemachinedemo.go (called from demo.go's main()) mirrors the Java StateMachineDemo exactly:

package main

import "fmt"

// runStateMachineDemo walks the legal happy paths end to end against the
// State-pattern ATM.
func runStateMachineDemo() {
	fmt.Println("-- M1: insert card, authenticate --")
	account := NewAccount(50000) // $500.00 opening balance
	dispenser := NewCashDispenser([]int64{5000, 2000, 1000}, []int{4, 4, 4})
	machine := NewATM(account, dispenser)

	fmt.Println(machine.InsertCard(&Card{Number: "4111-...-0001"}))
	fmt.Println(machine.EnterPIN(9999)) // wrong PIN, attempt 1/3
	fmt.Println(machine.EnterPIN(4321)) // correct PIN

	fmt.Println()
	fmt.Println("-- M2/M3: select + confirm a withdrawal, greedy notes --")
	fmt.Println(machine.SelectWithdrawal(13000)) // $130.00
	fmt.Println(machine.Confirm())
	fmt.Println("balance after =", account.Balance(), "c")
	fmt.Println("notes remaining =", dispenser.NoteCounts())

	fmt.Println()
	fmt.Println("-- insufficient funds (dedicated small account, notes are NOT the constraint) --")
	smallAccount := NewAccount(3000) // $30.00 balance only
	ampleDispenser := NewCashDispenser([]int64{5000, 2000, 1000}, []int{10, 10, 10})
	smallMachine := NewATM(smallAccount, ampleDispenser)
	fmt.Println(smallMachine.InsertCard(&Card{Number: "4111-...-0003"}))
	fmt.Println(smallMachine.EnterPIN(4321))
	fmt.Println(smallMachine.SelectWithdrawal(10000)) // $100 -- notes CAN make this, balance can't cover it
	fmt.Println(smallMachine.Confirm())

	fmt.Println()
	fmt.Println("-- M4: cancel mid-flow, before confirming --")
	fmt.Println(machine.InsertCard(&Card{Number: "4111-...-0001"}))
	fmt.Println(machine.EnterPIN(4321))
	fmt.Println(machine.SelectWithdrawal(2000))
	fmt.Println(machine.Cancel())
	fmt.Println("balance unchanged (no additional debit) =", account.Balance(), "c")

	fmt.Println()
	fmt.Println("-- 3 wrong PINs -- card ejected --")
	fmt.Println(machine.InsertCard(&Card{Number: "4111-...-0002"}))
	fmt.Println(machine.EnterPIN(1111))
	fmt.Println(machine.EnterPIN(2222))
	fmt.Println(machine.EnterPIN(3333))
	fmt.Println("state =", machine.CurrentState())
}

demo.go — the single entry point (Go permits exactly one main() per binary, so all three movements' demos run from here, delimited by section headers in the output):

package main

import "fmt"

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

Actual captured output from go run . (state-machine section only — Break It and Optimise sections are shown in their own movements below):

-- M1: insert card, authenticate --
card inserted -> CARD_INSERTED
REJECTED: incorrect PIN (1/3 attempts)
PIN correct -> PIN_AUTHENTICATED

-- M2/M3: select + confirm a withdrawal, greedy notes --
withdrawal selected -> SELECTING_TRANSACTION (call Confirm())
DISPENSED 13000c dispensed as [2 1 1] -> IDLE (card ejected)
balance after = 37000 c
notes remaining = [2 3 3]

-- insufficient funds (dedicated small account, notes are NOT the constraint) --
card inserted -> CARD_INSERTED
PIN correct -> PIN_AUTHENTICATED
withdrawal selected -> SELECTING_TRANSACTION (call Confirm())
ABORTED: insufficient funds (available=3000c) -> IDLE (card ejected, account untouched or hold released)

-- M4: cancel mid-flow, before confirming --
card inserted -> CARD_INSERTED
PIN correct -> PIN_AUTHENTICATED
withdrawal selected -> SELECTING_TRANSACTION (call Confirm())
cancelled before confirming, card ejected -> IDLE (nothing was ever reserved)
balance unchanged (no additional debit) = 37000 c

-- 3 wrong PINs -- card ejected --
card inserted -> CARD_INSERTED
REJECTED: incorrect PIN (1/3 attempts)
REJECTED: incorrect PIN (2/3 attempts)
REJECTED: incorrect PIN, 3rd attempt -- card ejected -> IDLE
state = IdleState

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

5. Break it

Two real breaks, both run against the actual reference implementation above, both captured verbatim.

1 — illegal transitions, the same two attempts from Movement 1, run against the real State machine. Authenticate before a card is inserted; try to dispense before a PIN was ever entered. Full file, copy-paste compilable:

import atm.ATM;
import atm.Account;
import atm.Card;
import atm.CashDispenser;
import atm.NaiveWithdrawal;
import atm.WithdrawalService;
import atm.WithdrawalResult;

/**
 * Two real breaks: (1) the illegal-transition attempts the State pattern
 * rejects with no special-case guard anywhere, and (2) the debit-then-
 * dispense bug that leaves a customer debited with no cash after a
 * simulated jam -- contrasted against the reserve/commit-or-release fix.
 */
public final class BreakItDemo {
    public static void main(String[] args) {
        System.out.println("== 1. Illegal transitions -- impossible by construction ==");
        Account acct1 = new Account(50000);
        CashDispenser disp1 = new CashDispenser(new long[]{5000, 2000, 1000}, new int[]{4, 4, 4});
        ATM machine = new ATM(acct1, disp1);

        System.out.println("[attempt: authenticate before a card is inserted]");
        System.out.println(machine.enterPin(4321));             // REJECTED, state=IDLE
        System.out.println("[attempt: dispense before authenticating]");
        System.out.println(machine.insertCard(new Card("card-A")));
        System.out.println(machine.confirm());                   // REJECTED, state=CARD_INSERTED -- no PIN yet
        System.out.println(machine.selectWithdrawal(1000));       // REJECTED, state=CARD_INSERTED -- no PIN yet
        System.out.println("state after both illegal attempts = " + machine.currentState());

        System.out.println();
        System.out.println("== 2. The atomicity bug: naive debit-then-dispense ==");
        Account naiveAccount = new Account(50000);
        CashDispenser naiveDispenser = new CashDispenser(new long[]{5000, 2000, 1000}, new int[]{4, 4, 4});
        naiveDispenser.forceJamOnNextDispense();                  // hardware jam, simulated
        long before = naiveAccount.balanceCents();
        boolean naiveOk = NaiveWithdrawal.withdraw(naiveAccount, naiveDispenser, 13000);
        long after = naiveAccount.balanceCents();
        System.out.println("naive withdraw(13000c) returned " + naiveOk + " (jam simulated)");
        System.out.println("balance BEFORE = " + before + "c, AFTER = " + after + "c");
        if (before == after) throw new AssertionError("expected the naive bug to debit despite the jam");
        System.out.println("THE BUG: " + (before - after) + "c vanished -- debited, but no cash was ever dispensed.");

        System.out.println();
        System.out.println("== 3. The fix: reserve -> dispense -> commit-or-release ==");
        Account safeAccount = new Account(50000);
        CashDispenser safeDispenser = new CashDispenser(new long[]{5000, 2000, 1000}, new int[]{4, 4, 4});
        safeDispenser.forceJamOnNextDispense();                   // identical simulated jam
        long safeBefore = safeAccount.balanceCents();
        WithdrawalResult result = WithdrawalService.withdraw(safeAccount, safeDispenser, 13000);
        long safeAfter = safeAccount.balanceCents();
        System.out.println("safe withdraw(13000c) -> success=" + result.success + ", detail=" + result.detail);
        System.out.println("balance BEFORE = " + safeBefore + "c, AFTER = " + safeAfter + "c");
        if (safeBefore != safeAfter) throw new AssertionError("expected the reserve/release fix to leave balance untouched");
        System.out.println("Balance is unchanged -- the hold was released, no debit ever happened.");
    }
}

Run it — actual output, captured verbatim from java BreakItDemo (sections 2 and 3 are the atomicity bug and its fix, run back to back against the identical simulated jam):

== 1. Illegal transitions -- impossible by construction ==
[attempt: authenticate before a card is inserted]
REJECTED: insert a card before entering a PIN (state=IDLE)
[attempt: dispense before authenticating]
card inserted -> CARD_INSERTED
REJECTED: nothing to confirm (state=CARD_INSERTED)
REJECTED: enter your PIN before selecting a transaction (state=CARD_INSERTED)
state after both illegal attempts = CardInsertedState

== 2. The atomicity bug: naive debit-then-dispense ==
naive withdraw(13000c) returned false (jam simulated)
balance BEFORE = 50000c, AFTER = 37000c
THE BUG: 13000c vanished -- debited, but no cash was ever dispensed.

== 3. The fix: reserve -> dispense -> commit-or-release ==
safe withdraw(13000c) -> success=false, detail=dispenser jam -- hold released, account NOT debited
balance BEFORE = 50000c, AFTER = 50000c
Balance is unchanged -- the hold was released, no debit ever happened.

Section 1: both illegal attempts are rejected with a clear reason and zero corrupted state — and notice there is no line of code anywhere that says "if state is CARD_INSERTED, reject confirm." CardInsertedState.confirm() simply has no dispensing logic to fall back to. That is the State pattern's whole payoff from Movement 3, proven, not asserted.

Sections 2 and 3 are the actual point of this kata. NaiveWithdrawal.withdraw() (shown in full in the Reference Implementation above) debits the account, THEN attempts the dispense; WithdrawalService.withdraw() reserves, attempts the dispense, and only then commits or releases. Both run against an identical simulated jam and an identical starting balance of 50000c, withdrawing 13000c. Look closely: the naive version's balance moves from 50000c to 37000c even though dispense() returned false — the customer's $130 is gone and no cash ever left the machine. The WithdrawalService version's balance stays at 50000c, unchanged, because the reserve was released instead of committed the instant dispense() reported failure. Same jam, same request, opposite outcome — entirely because of which order the debit and the dispense happen in, and whether there's a reversible hold in between them. This is not a rare interleaving or a race that needs bad luck to trigger — it reproduces on every single run, single-threaded, because the naive design never had a mechanism to prevent it; it only had a mechanism you'd need to remember to add.

The Go version reproduces the identical shape of every check (illegal transitions AND the atomicity fix) deterministically under go run .:

== 1. Illegal transitions -- impossible by construction ==
[attempt: authenticate before a card is inserted]
REJECTED: insert a card before entering a PIN (state=IDLE)
[attempt: dispense before authenticating]
card inserted -> CARD_INSERTED
REJECTED: nothing to confirm (state=CARD_INSERTED)
REJECTED: enter your PIN before selecting a transaction (state=CARD_INSERTED)
state after both illegal attempts = CardInsertedState

== 2. The atomicity bug: naive debit-then-dispense ==
naive withdraw(13000c) returned false (jam simulated)
balance BEFORE = 50000 c, AFTER = 37000 c
THE BUG: 13000 c vanished -- debited, but no cash was ever dispensed.

== 3. The fix: reserve -> dispense -> commit-or-release ==
safe withdraw(13000c) -> success= false , detail= dispenser jam -- hold released, account NOT debited
balance BEFORE = 50000 c, AFTER = 50000 c
Balance is unchanged -- the hold was released, no debit ever happened.

The Go source for this section is breakitdemo.go, full file, copy-paste compilable (it calls only WithdrawNaive, WithdrawReserveCommit, and the ATM/Account/CashDispenser methods already defined in the Reference Implementation above — nothing hand-waved):

package main

import "fmt"

// runBreakItDemo reproduces two real breaks: (1) illegal-transition attempts
// the State pattern rejects with no special-case guard anywhere, and (2) the
// debit-then-dispense bug that leaves a customer debited with no cash after
// a simulated jam -- contrasted against the reserve/commit-or-release fix.
func runBreakItDemo() {
	fmt.Println("== 1. Illegal transitions -- impossible by construction ==")
	acct1 := NewAccount(50000)
	disp1 := NewCashDispenser([]int64{5000, 2000, 1000}, []int{4, 4, 4})
	machine := NewATM(acct1, disp1)

	fmt.Println("[attempt: authenticate before a card is inserted]")
	fmt.Println(machine.EnterPIN(4321)) // REJECTED, state=IDLE
	fmt.Println("[attempt: dispense before authenticating]")
	fmt.Println(machine.InsertCard(&Card{Number: "card-A"}))
	fmt.Println(machine.Confirm())              // REJECTED, state=CARD_INSERTED -- no PIN yet
	fmt.Println(machine.SelectWithdrawal(1000)) // REJECTED, state=CARD_INSERTED -- no PIN yet
	fmt.Println("state after both illegal attempts =", machine.CurrentState())

	fmt.Println()
	fmt.Println("== 2. The atomicity bug: naive debit-then-dispense ==")
	naiveAccount := NewAccount(50000)
	naiveDispenser := NewCashDispenser([]int64{5000, 2000, 1000}, []int{4, 4, 4})
	naiveDispenser.ForceJamOnNextDispense() // hardware jam, simulated
	before := naiveAccount.Balance()
	naiveOk := WithdrawNaive(naiveAccount, naiveDispenser, 13000)
	after := naiveAccount.Balance()
	fmt.Println("naive withdraw(13000c) returned", naiveOk, "(jam simulated)")
	fmt.Println("balance BEFORE =", before, "c, AFTER =", after, "c")
	if before == after {
		panic("expected the naive bug to debit despite the jam")
	}
	fmt.Println("THE BUG:", before-after, "c vanished -- debited, but no cash was ever dispensed.")

	fmt.Println()
	fmt.Println("== 3. The fix: reserve -> dispense -> commit-or-release ==")
	safeAccount := NewAccount(50000)
	safeDispenser := NewCashDispenser([]int64{5000, 2000, 1000}, []int{4, 4, 4})
	safeDispenser.ForceJamOnNextDispense() // identical simulated jam
	safeBefore := safeAccount.Balance()
	result := WithdrawReserveCommit(safeAccount, safeDispenser, 13000)
	safeAfter := safeAccount.Balance()
	fmt.Println("safe withdraw(13000c) -> success=", result.Success, ", detail=", result.Detail)
	fmt.Println("balance BEFORE =", safeBefore, "c, AFTER =", safeAfter, "c")
	if safeBefore != safeAfter {
		panic("expected the reserve/release fix to leave balance untouched")
	}
	fmt.Println("Balance is unchanged -- the hold was released, no debit ever happened.")
}

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 — 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 to get wrong — 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 (debit, dispense, refund) still need code per transitionAdd rows/entries to the table — no new classAdd a column; still need to write the side-effect code somewhere and wire it to the lookupThe state graph itself is complex and worth visualizing/validating, and each transition's side effect is thin or shared — a middle ground, not a universal upgrade over State

The real judgment call: reach for the explicit table when the interesting complexity is the shape of the graph itself 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 — reserving funds, dispensing notes, refunding — because that logic needs a real method body, not a table cell.

Withdrawal strategyWhat it doesFailure mode it leaves openWhen it's the right call
Debit-then-dispense (Movement 1/5's trap)Debit the ledger, then attempt the physical dispenseDispense fails AFTER the debit committed — money vanishes, no compensating step existsNever, for any operation where the second step can fail independently of the first — this row exists to be rejected
Reserve → dispense → commit-or-release (this kata)Earmark funds (no debit), attempt the dispense, then either commit the debit or release the holdNone locally — but the hold must be reconciled if the PROCESS itself crashes mid-sequence (see Movement 7's "machine dies mid-dispense")The default for any single-process, two-system operation (ledger + hardware, or ledger + any other side effect) where you control both call sites directly
Two-phase commit (2PC)A coordinator asks both the ledger service and the dispenser service to "prepare," then "commit" only once both said yesThe coordinator itself is a single point of failure, and a participant that acks "prepared" then crashes can block the whole transaction indefinitely (blocking protocol)Both sides are genuinely separate services behind a network boundary AND you need strict all-or-nothing atomicity with no eventual-consistency window — rare for a single ATM, common for core banking transfers between two different banks' systems
Saga / compensating transactionRun each step independently; if a later step fails, run an explicit compensating action (e.g., a credit) to undo an earlier committed stepThere's a real window where the debit is visible before the compensation lands — the customer could theoretically see a debited balance for a few hundred millisecondsDistributed, multi-service flows where 2PC's blocking coordinator is unacceptable and a brief inconsistency window is tolerable — the ATM-to-core-banking hop at real scale (Movement 7)
Per-session locking (this kata's synchronized ATM + separate Account lock)Serializes this machine's own events with one lock; serializes the shared ledger with a second, independent lockNone for a single account under this ATM's own traffic; a second ATM or the mobile app hitting the SAME account still serializes correctly because it goes through the Account's own lock, not the ATM'sAlways, for the account side — conflating the ATM's lock with the account's lock is the bug Movement 7's "concurrent access from ATM + app" question is designed to catch

Greedy vs. DP note selection — where greedy provably breaks. M3's planGreedy is greedy: take as many of the largest denomination as fit, then the next, and so on. That's optimal for a canonical note system — and standard bills ($50/$20/$10/$5/$1) are canonical, so greedy is not just simple, it's provably correct there. It is not generally optimal for an arbitrary denomination set, and it can do worse than being merely sub-optimal: it can flat-out REJECT an amount that a smarter search could still dispense exactly.

import java.util.Arrays;

/** Greedy vs. DP note selection -- canonical bills are fine with greedy; a
 *  non-canonical denomination set can make greedy reject an amount that a
 *  smarter search could still dispense exactly. */
public final class DenominationDemo {

    static int[] greedyPlan(long amount, long[] denomsDesc, int[] counts) {
        int[] plan = new int[denomsDesc.length];
        long remaining = amount;
        for (int i = 0; i < denomsDesc.length; i++) {
            long take = Math.min(counts[i], remaining / denomsDesc[i]);
            plan[i] = (int) take;
            remaining -= take * denomsDesc[i];
        }
        return remaining == 0 ? plan : null;
    }

    /** Classic unbounded minimum-notes DP (same simplification the vending-
     *  machine kata's DP makes: it ignores the dispenser's per-denom count
     *  limit for clarity -- a real dispenser would additionally track
     *  remaining count per denomination in the DP state, a mechanical
     *  extension). dp[a] = fewest notes to make amount a. */
    static int[] dpMinNotes(long amount, long[] denoms) {
        int amt = (int) amount;
        int[] dp = new int[amt + 1];
        int[] lastDenomIdx = new int[amt + 1];
        Arrays.fill(dp, Integer.MAX_VALUE);
        dp[0] = 0;
        for (int a = 1; a <= amt; a++) {
            for (int i = 0; i < denoms.length; i++) {
                int d = (int) denoms[i];
                if (d <= a && dp[a - d] != Integer.MAX_VALUE && dp[a - d] + 1 < dp[a]) {
                    dp[a] = dp[a - d] + 1;
                    lastDenomIdx[a] = i;
                }
            }
        }
        if (dp[amt] == Integer.MAX_VALUE) return null;
        int[] plan = new int[denoms.length];
        int a = amt;
        while (a > 0) {
            int i = lastDenomIdx[a];
            plan[i]++;
            a -= (int) denoms[i];
        }
        return plan;
    }

    public static void main(String[] args) {
        System.out.println("-- Canonical bills {5000,2000,1000}c ($50/$20/$10), amount=13000c ($130) --");
        long[] canonical = {5000, 2000, 1000};
        int[] canonicalCounts = {4, 4, 4};
        int[] g1 = greedyPlan(13000, canonical, canonicalCounts);
        System.out.println("greedy: " + Arrays.toString(g1) + " -- exact, matches the reference impl's own demo");

        System.out.println();
        System.out.println("-- Non-canonical notes {4000,3000}c ($40/$30), amount=6000c ($60) --");
        long[] nonCanonical = {4000, 3000};
        int[] nonCanonicalCounts = {5, 5};
        int[] g2 = greedyPlan(6000, nonCanonical, nonCanonicalCounts);
        int[] d2 = dpMinNotes(6000, nonCanonical);
        System.out.println("greedy: " + (g2 == null ? "null -- REJECTS the withdrawal" : Arrays.toString(g2)));
        System.out.println("dp:     " + (d2 == null ? "null" : Arrays.toString(d2) + " -- e.g. two $30 notes, exact"));
        if (g2 != null) throw new AssertionError("expected greedy to fail on the non-canonical set");
        if (d2 == null) throw new AssertionError("expected dp to find the 2x$30 combo");
    }
}

Actual output, java DenominationDemo:

-- Canonical bills {5000,2000,1000}c ($50/$20/$10), amount=13000c ($130) --
greedy: [2, 1, 1] -- exact, matches the reference impl's own demo

-- Non-canonical notes {4000,3000}c ($40/$30), amount=6000c ($60) --
greedy: null -- REJECTS the withdrawal
dp:     [0, 2] -- e.g. two $30 notes, exact

With only $50/$20/$10 notes, greedy correctly makes $130 as [2, 1, 1] — the canonical case, no surprises. But with a deliberately non-canonical note set of just $40 and $30 bills, asking for $60: greedy takes one $40 first (largest fits), leaving $20, which no remaining denomination can make exactly — greedy REJECTS the withdrawal outright, even though two $30 notes make $60 exactly and both are sitting in the drawer. Greedy never reconsiders the $40 it already committed to; it has no backtracking. The DP formulation (classic minimum-notes-to-make-amount, exploring every combination) finds the 2×$30 answer immediately. This is the sharper cousin of the vending-machine kata's {1,3,4} counterexample: there, greedy was merely sub-optimal (more coins than necessary); here, greedy fails to find ANY exact answer even though one exists — for a cash withdrawal, that's the difference between "gave slightly clunky change" and "rejected a withdrawal the machine could have serviced."

The trade-off is the same shape as the vending kata's: greedy costs O(denominations) and is correct whenever the note set is canonical (true for essentially every real ATM, which is stocked in standard bank-note denominations); DP costs O(amount × denominations) and is correct for ANY denomination set. Shipping greedy in production and reserving DP for a denomination set you can't vouch for (a points/rewards system with odd conversion rates, say) is the correct call, not a shortcut — provided you've actually checked that your note set is canonical, which is precisely the check this counterexample exists to motivate.

Same comparison, Go (denominationdemo.go, called from demo.go's main()):

package main

import "fmt"

func greedyNotePlan(amount int64, denomsDesc []int64, counts []int) []int {
	plan := make([]int, len(denomsDesc))
	remaining := amount
	for i, d := range denomsDesc {
		take := remaining / d
		if take > int64(counts[i]) {
			take = int64(counts[i])
		}
		plan[i] = int(take)
		remaining -= take * d
	}
	if remaining != 0 {
		return nil
	}
	return plan
}

// dpMinNotes is the classic unbounded minimum-notes DP (same simplification
// the vending-machine kata's DP makes: it ignores the dispenser's per-denom
// count limit for clarity -- a real dispenser would additionally track
// remaining count per denomination in the DP state, a mechanical extension).
func dpMinNotes(amount int64, denoms []int64) []int {
	amt := int(amount)
	const inf = 1 << 30
	dp := make([]int, amt+1)
	last := make([]int, amt+1)
	for i := 1; i <= amt; i++ {
		dp[i] = inf
	}
	for a := 1; a <= amt; a++ {
		for i, d := range denoms {
			di := int(d)
			if di <= a && dp[a-di] != inf && dp[a-di]+1 < dp[a] {
				dp[a] = dp[a-di] + 1
				last[a] = i
			}
		}
	}
	if dp[amt] == inf {
		return nil
	}
	plan := make([]int, len(denoms))
	a := amt
	for a > 0 {
		i := last[a]
		plan[i]++
		a -= int(denoms[i])
	}
	return plan
}

// runDenominationDemo shows greedy vs. DP note selection: canonical bills
// are fine with greedy; a non-canonical denomination set can make greedy
// reject an amount that a smarter search could still dispense exactly.
func runDenominationDemo() {
	fmt.Println("-- Canonical bills {5000,2000,1000}c ($50/$20/$10), amount=13000c ($130) --")
	canonical := []int64{5000, 2000, 1000}
	canonicalCounts := []int{4, 4, 4}
	g1 := greedyNotePlan(13000, canonical, canonicalCounts)
	fmt.Println("greedy:", g1, "-- exact, matches the reference impl's own demo")

	fmt.Println()
	fmt.Println("-- Non-canonical notes {4000,3000}c ($40/$30), amount=6000c ($60) --")
	nonCanonical := []int64{4000, 3000}
	nonCanonicalCounts := []int{5, 5}
	g2 := greedyNotePlan(6000, nonCanonical, nonCanonicalCounts)
	d2 := dpMinNotes(6000, nonCanonical)
	if g2 == nil {
		fmt.Println("greedy: nil -- REJECTS the withdrawal")
	} else {
		fmt.Println("greedy:", g2)
	}
	if d2 == nil {
		fmt.Println("dp:     nil")
	} else {
		fmt.Println("dp:    ", d2, "-- e.g. two $30 notes, exact")
	}
	if g2 != nil {
		panic("expected greedy to fail on the non-canonical set")
	}
	if d2 == nil {
		panic("expected dp to find the 2x$30 combo")
	}
}

Actual output, go run . (this section only):

-- Canonical bills {5000,2000,1000}c ($50/$20/$10), amount=13000c ($130) --
greedy: [2 1 1] -- exact, matches the reference impl's own demo

-- Non-canonical notes {4000,3000}c ($40/$30), amount=6000c ($60) --
greedy: nil -- REJECTS the withdrawal
dp:     [0 2] -- e.g. two $30 notes, exact

Identical numbers, identical conclusion, verified with go build ./... and go vet ./... clean before publishing.

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 -Werror + java; gofmt, go vet, go build, go run) before publishing; the illegal-transition rejections and both atomicity-bug demonstrations (naive vs. reserve/commit-or-release) reproduce deterministically on every run, in both languages, with the numbers shown captured verbatim. See also: the State Pattern concept page and the Design an ATM walkthrough.

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

Stuck on Design an ATM? 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 an ATM** (Hands-On Builds) and want to truly understand it. Explain Design an ATM 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 an ATM** 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 an ATM** 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 an ATM** 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