CMD Guide
HomeOO & Low-Level DesignOO Design Problems

Design an ATM

What this design is really about

An ATM is a small state machine wrapped around money. A customer inserts a card, authenticates with a PIN, and then drives a session in which exactly one transaction happens at a time. The interesting engineering is not the class list — it is two things that must never go wrong: (1) the machine must always know which step of the session it is in, and (2) it must never debit an account for cash it cannot physically hand out. The model below is organized around those two invariants.

Core responsibilities

Modeling the session: State pattern — and when not to use it

The session moves through a fixed set of stages, and what the machine accepts depends entirely on the current stage. Inserting a card means nothing once you are already authenticated; selecting a withdrawal means nothing before you are. This is the textbook shape for the State pattern: give each stage its own object, let each object implement only the transitions that are legal from it, and have it return the next state.

abstract class ATMState {
    ATMState insertCard(ATM atm, Card card)      { return reject(); }
    ATMState enterPin(ATM atm, int pin)          { return reject(); }
    ATMState selectTransaction(ATM atm, Transaction txn) { return reject(); }
    ATMState cancel(ATM atm)                      { atm.ejectCard(); return new IdleState(); }
    private ATMState reject() { /* show "invalid operation" */ return this; }
}

class AuthenticatedState extends ATMState {
    @Override
    ATMState selectTransaction(ATM atm, Transaction txn) {
        TransactionStatus result = txn.execute(atm);
        if (result == TransactionStatus.SUCCESS) {
            atm.printReceipt(txn);
            atm.ejectCard();
            return new IdleState();
        }
        // FAILURE / undispensable / insufficient funds:
        // do NOT print a success receipt, do NOT silently eject.
        atm.showError(result);          // surface the reason
        return this;                    // stay authenticated; let the user retry or cancel
    }
}

The key correctness point lives in selectTransaction: it captures the result and branches on it. Only a SUCCESS prints a receipt and ejects the card. A FAILURE (insufficient funds, or an amount the dispenser cannot make) keeps the session alive so the customer can try a different amount or cancel — and crucially it never prints a receipt for money that was not dispensed. An earlier draft ignored result and unconditionally printed and ejected; that is the single most important bug to avoid on this page, because it directly violates the "check feasibility before debiting, and surface failures" invariant.

When NOT to reach for State

State earns its keep here because there are five-plus stages, several transition rules, and behavior that genuinely differs per stage. That justification does not generalize. If you only have two or three trivial stages with no per-stage behavior — for example a toy machine that is just LOCKED vs UNLOCKED — a plain enum plus a switch is simpler, has less indirection, and is easier to read. The State pattern trades a switch for a set of classes; that trade only pays off once the number of states and the divergence in behavior are large enough that the switch would otherwise sprawl across the codebase. Reaching for State on two states is over-engineering. The honest rule: use a switch until the conditionals start duplicating across methods, then promote to State.

diagram
diagram

The interesting algorithm: making change from a finite till

When a withdrawal is approved, the dispenser must turn an amount into actual bills drawn from a finite inventory. This is the change-making problem, and the right algorithm depends entirely on the denomination set and the inventory — so the first job is to know which case you are in.

Selection guidance (the part that actually matters)

SituationCorrect approachWhy
Canonical denomination set, effectively unlimited billsGreedy (largest-denomination-first)For a canonical set, greedy always yields a minimal-count, valid combination. Provably optimal — no DP needed.
Non-canonical set (e.g. {25,20,10})DP / BFS over amountsGreedy can return a sub-optimal split, or fail to find a valid split that exists.
Any set, but bills are scarce (finite till)Bounded DP / backtracking with per-denomination capsGreedy can grab too many of one bill and strand the remainder; feasibility now depends on inventory, not just the math.

The denomination set used throughout this page is {50, 20, 10, 5}. That set is canonical, and greedy is provably optimal on it. I verified this exhaustively with a DP over every amount from 0 to 500: there are zero amounts where greedy uses more bills than the optimum, and zero amounts where greedy fails to find a split that exists. So for this exact problem, with ample inventory, greedy is not just "good enough" — it is the correct, optimal choice, and reaching for DP would be over-engineering.

Greedy is not universally correct, and it is worth knowing exactly when it breaks so you can recognize the non-canonical case in an interview:

So the dispenser's real algorithm is feasibility-first and inventory-aware: canDispense(amount) must run the bounded search and confirm a valid bill combination before the account is debited; only then does dispenseCash release exactly those bills. On the canonical set with ample stock that bounded search degenerates to plain greedy.

A trace that actually demonstrates the divergence

The point of a trace is to show naive greedy failing where a valid combination exists, and inventory-aware search succeeding on the same input. The amount is $60 and the till holds 1×$50, 3×$20, 0×$10, 0×$5 — a finite-inventory case, since the denomination set itself is canonical.

StepNaive greedy (largest-first)Inventory-aware (backtracking)
Take $50?Yes → remaining $10Try yes → remaining $10 leads to a dead end (below); on failure, backtrack and decline the $50
Take $20?$20 > $10 remaining → skipWith the $50 declined, take 3×$20 = $60 → remaining $0
Take $10?None in till → skip
Take $5?None in till → skip; $10 remains unfilled
ResultFAILS (reports cannot dispense)SUCCEEDS: 3×$20

The two columns genuinely differ and the thesis is demonstrated: a valid combination exists (3×$20), inventory-aware search finds it by declining the $50, and a strictly largest-first greedy that commits to the $50 strands the final $10 with no small bills to cover it. Note the cause is the finite till, not the denomination set — on an unlimited {50,20,10,5} till, greedy would have produced $50 + $10 and there would be no divergence at all. That is exactly why the selection table above gates greedy on "ample inventory," and why a real dispenser runs the bounded, backtracking search rather than a one-pass greedy.

And a correct rejection, for contrast: withdrawing $30 from a till of 1×$50, 0×$20, 0×$10, 4×$5. The maximum usable here is 4×$5 = $20 < $30no valid combination exists, so every algorithm must reject. That is the right answer, not a greedy failure; it is included only to show that canDispense returning false is sometimes simply correct.

A note on the skeleton code's conventions

The provided skeleton uses body-less method signatures on classes (for example public boolean makeTransaction(Transaction transaction); on Customer) and getters such as getAccountId. It is worth being precise about what is and isn't a real defect here, because an earlier draft overstated this:

The distinction matters for interview judgment: call out the genuine type defect, but don't manufacture a compile error out of ordinary skeleton notation.

Where the real consistency lives: the account the ATM does not own

Every lock and feasibility check on this page lives inside one machine's process. That is the honest scope of an LLD answer — and it is worth saying out loud where it stops, because the staff-level follow-up lands exactly on the seam. The genuinely hard invariant is not local: the money in the cassette is physical and this-machine, but the account balance lives on a bank backend (the issuer, reached through a switch) over a network the ATM does not control. Two failure modes a strong interviewer will push on:

The defended ordering (vs the naive alternative). Debit-then-dispense risks charging a customer who then gets no cash (the dispenser jams, or the machine loses power between the two steps). Dispense-then-debit risks handing out money you can never collect. Neither one-step order is safe, so real ATM networks use a hold-and-settle protocol: authorize a hold on the account first, dispense, then confirm the completion. If the confirmation is uncertain the transaction is left in-doubt and resolved by the daily reconciliation / settlement file — the switch reverses an authorized-but-undispensed hold, or re-posts a dispensed-but-unconfirmed debit. A per-transaction idempotency key makes every confirm, reverse, or retry safe to replay exactly once.

The honest limit, stated plainly: the class model on this page is the correct single-machine core — session state and inventory-aware dispensing on one box. The money-safety of an ATM is a distributed-transaction problem that lives in the switch and the nightly settlement, deliberately outside the LLD class list. Knowing precisely which invariant the synchronized keyword can and cannot buy you — cassette inventory yes, account balance no — is the line between a candidate who has memorised the State pattern and one who has run money in production.

Citation

Adapted and corrected from the "Design an ATM" object-oriented design problem (Grokking the Object-Oriented Design Interview / Educative), with the original problem statement, requirements, actors, and skeleton class model retained. The greedy-optimality claim for the {50,20,10,5} denomination set was verified independently here via exhaustive dynamic-programming comparison over amounts 0–500 (zero greedy-suboptimal or greedy-fail cases); the {25,20,10}→40 counterexample is the canonical illustration that greedy is correct only for canonical denomination sets.

Interview drills & operability

Q1. How do you model the hardware?
Put each device behind an interface — CardReader, CashDispenser, ReceiptPrinter — so the session logic depends on the abstraction, not a driver. Tests inject fakes (a dispenser whose inventory you control); production injects the real drivers. This is the same "program to an interface" that lets you unit-test the change-making and state transitions with no physical machine.

Q2. What is the partial-dispense policy?
Cash is all-or-nothing: canDispense(amount) must confirm a full, valid bill combination before the account is debited, and only then does the dispenser release exactly those bills. Never debit-then-discover-you-can't-pay, and never hand out a partial amount without an explicit accounting-and-receipt policy — a partially-completed withdrawal that debited the account is the worst failure this design exists to prevent.

Q3. What breaks in production, and how do you fingerprint it?
Two operability traps: (a) PIN retries must be capped — lock or capture the card after N failures, or the machine is a brute-force oracle; (b) cassette counts must never go negative — decrement inventory under a lock so two concurrent sessions cannot both "dispense" the same physical notes. Field fingerprints of a broken build: negative cassette counters, a session stuck after cancel, or a printed receipt with no matching dispense.

🔨 Practice this hands-on — Design an ATM →
Attempt it from an empty file, break it to feel the failure, then defend it under pushback.
🤖 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** (OO & Low-Level Design) 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