ATM LLD: A State Machine You'd Trust with Your Salary
A low-level design walkthrough of an ATM: a state machine that retains cards after three wrong PINs, a bank service interface because the ATM owns no truth, and cash planned before any debit.
"Design an ATM." Of all the machines in this series, this is the one you already trust with your salary — and that trust is a list of design decisions. Why does the machine eat your card after three wrong PINs instead of just sulking? Why does it count out your notes before your account is touched? Why, when the network dies mid-withdrawal, do you get your money or your balance back — never neither?
This is the Phase 1 finale, and fittingly, it's a synthesis: the vending machine's state machine, its cash-counting cousin, and the inject-your-dependencies reflex from four games running now — all pointed at the least forgiving domain there is: other people's money.
Let's start nowhere near a computer
Think about what an ATM actually is, socially: a security guard with a cash box, standing very far from the bank. The guard knows nothing. Not your balance, not your PIN, not whether your account exists. For every question, the guard radios headquarters: "card ending 7042 says PIN 4-3-2-1 — yes or no?" — "may I give them ₹2,700 — yes or no?"
What the guard does own is procedure: no questions before a card is inserted, three strikes on the PIN and the card goes in the drawer, count the notes before confirming the debit over the radio. The intelligence lives at headquarters; the discipline lives at the kiosk. Keep those straight and the whole design falls out.
You've met this machine's relatives
- The vending machine — same skeleton: states gate the buttons, cash math can refuse a transaction. The ATM adds the radio.
- Every payment terminal and POS device — a dumb, disciplined edge talking to a smart, distant core.
- OAuth login flows — insert card / enter PIN / authenticated session is exactly redirect / credentials / token, with the same three-strikes lockout.
Step 1 — Functional requirements (sentences first)
What the machine must do, as plain sentences — the functional requirements.
- A customer inserts a card, then proves the PIN; three failures and the machine retains the card.
- An authenticated customer can check their balance or withdraw cash.
- A withdrawal must be covered by the account and composable from the notes in the machine.
- The customer can eject the card at any point; the machine returns to idle.
- The machine never decides balances — the bank does.
That last sentence is a deliberate stance. The kiosk owning a copy of your balance doesn't make the bank's number disappear — it invents a second number that can disagree with the first, and now two ATMs can both authorize against stale truth. The single source of truth is a feature. The guard radios every question, every time.
Step 2 — Non-functional requirements
At class level the non-functional requirements are different words for the same idea — how well, not just what — and for an ATM, money makes them non-negotiable:
- Correctness. The notes handed out sum to exactly the amount requested; balance and cash inventory always reconcile; the machine never dispenses a note it doesn't hold.
- Safety / atomicity. Debit and dispense succeed together or neither happens — a mid-transaction failure must lose nobody's money, the customer's or the bank's.
- Extensibility. New states (a deposit flow) and new transaction types plug into the state machine without rewriting the ones already there.
- Testability. Every state transition and every cash decision is independently checkable — the bank is an interface, so a test scripts account states like dice rolls.
Listing them is the easy half; the design only earns them if it fulfills them. Here's the contract — each requirement and the mechanism that keeps it:
| Requirement | How this design fulfills it |
|---|---|
| Correctness | plan composes the exact amount from held notes or returns empty; money is integer minor units — Steps 5, 6 |
| Safety / atomicity | plan → debit → commit order: arithmetic before the bank, notes leave the box only after the debit returns — Step 5 |
| Extensibility | states gate the buttons via an enum-and-guards machine; a new state is one more case — Step 4 |
| Testability | BankService is an injected interface; each transition throws on the wrong state, so it's checkable in isolation — Steps 3, 4 |
Every trade-off below is chosen to keep one of these.
Step 3 — Nouns → classes (and one load-bearing interface)
- Atm — the guard: states, the workflow, the discipline.
- BankService — the radio, and the design's center of gravity. An interface with three questions:
validatePin,balance,debit. The real one calls the bank's core; the test one is aHashMap. The ATM cannot tell the difference — which is the whole point. - CashCassette — the cash box: note counts and the skill of composing an amount, straight from the vending machine's CashBank, graduated to ₹100/₹200/₹500 notes.
- Card — for our scope, the card number the guard reads off the plastic.
The dependency-injection reflex, one last time, now with its sharpest payoff: the injected thing isn't a die or a tile spawner — it's the bank. Tests script account states the way they scripted dice rolls.
Which structure — and why. The cassette is a map of denomination→count (TreeMap, largest note first), not a flat list of notes — and that ordering is the load-bearing choice, not a default. Largest-first is what makes the greedy plan walk the shelves once and compose the exact amount, serving correctness directly. Money is an integer in minor units (whole rupees here), never a double — floating-point cents drift, and a balance that drifts is a correctness bug you can't reconcile away. And the lifecycle is a State machine (an enum with guards) rather than a tangle of booleans: each state names exactly which buttons are legal, which is what makes a new state pluggable (extensibility) and each transition checkable on its own (testability).
Step 4 — The state machine, with its card-eating edge
Three states gate every button: IDLE (a card slot and nothing else), AWAITING_PIN (a keypad and a ticking strike counter), AUTHENTICATED (the menu). And one edge no polite machine has: three wrong PINs and the card is retained — because the likeliest holder of a card with three wrong PINs isn't you.
public synchronized boolean enterPin(String pin) {
requireState(State.AWAITING_PIN);
if (bank.validatePin(cardNumber, pin)) {
state = State.AUTHENTICATED;
pinAttempts = 0;
return true;
}
pinAttempts++;
if (pinAttempts >= 3) {
retainedCards.add(cardNumber); // the drawer, not the slot
cardNumber = null;
state = State.IDLE;
throw new IllegalStateException("card retained — contact your bank");
}
return false;
}Same enum-and-guards shape as the vending machine — and the same honest pattern answer: three states don't need State classes. The ATM's grade isn't pattern ceremony; it's the edges being right.
Step 5 — The order of operations that earns the trust
Here's the question that separates this from every game in the series: a withdrawal touches two pots of truth — the account (at the bank) and the cash (in the cassette) — and they must never disagree. Watch the wrong orders fail:
- Debit first, count later: the cassette can't compose ₹300 from ₹500 notes → account charged, no cash. Customer files a dispute.
- Dispense first, debit later: the network hiccups on the debit → free money. The bank files charges.
The right order is plan → debit → commit:
public synchronized Map<Integer, Integer> withdraw(long amount) {
requireState(State.AUTHENTICATED);
if (amount <= 0) throw new IllegalArgumentException("amount must be positive");
Map<Integer, Integer> plan = cassette.plan(amount) // 1. count the notes — NOTHING moves
.orElseThrow(() -> new IllegalStateException(
"this machine can't dispense " + amount + " — try a multiple it can compose"));
bank.debit(cardNumber, amount); // 2. the bank says yes (or throws —
// and the cassette is untouched)
cassette.commit(plan); // 3. only now do notes leave the box
return plan;
}If step 2 throws — insufficient funds, dead network — the plan was just arithmetic; no note moved. If step 1 fails, the bank was never asked. The two pots can disagree in exactly zero orderings of failure. That's why you trust the machine.
The deep-end follow-up: what if the machine crashes between debit and commit? Real ATMs write step 2 to a local journal and the bank runs reconciliation — "debited but not dispensed" reverses automatically overnight. You don't need to build it; naming the journal is the senior answer to "what if the power dies?"
Step 6 — The cassette: greedy, with the VM's honesty
The plan/commit split keeps the cassette safe to ask without committing:
/** Largest notes first; empty if the amount can't be composed from this float. */
public Optional<Map<Integer, Integer>> plan(long amount) {
Map<Integer, Integer> plan = new LinkedHashMap<>();
long remaining = amount;
for (Map.Entry<Integer, Integer> shelf : notes.entrySet()) { // 500, 200, 100
int denom = shelf.getKey();
int use = (int) Math.min(remaining / denom, shelf.getValue());
if (use > 0) {
plan.put(denom, use);
remaining -= (long) use * denom;
}
}
return remaining == 0 ? Optional.of(plan) : Optional.empty();
}
public void commit(Map<Integer, Integer> plan) {
plan.forEach((denom, count) -> notes.merge(denom, -count, Integer::sum));
}Greedy works cleanly on ₹100/₹200/₹500 — and the vending machine's caveat transfers verbatim: with a limited float there are amounts greedy misses that a DP search would find. Name it, move on.
Step 7 — Trade-offs (each one keeping an NFR)
The last column is the discipline: every choice keeps one of the promises from Step 2 — that's what designing to the non-functional requirements looks like.
| Decision | The tempting alternative | Why ours wins | Keeps |
|---|---|---|---|
| plan → debit → commit order | dispense first, or debit first | the two pots of truth can't disagree in any failure ordering — no money lost | safety / atomicity |
plan returns empty, never partial | hand out the closest it can make | a withdrawal yields the exact amount or nothing — a short stack is a dispute | correctness |
| money as integer minor units | a double for rupees | no floating-point drift, so balance and cash always reconcile exactly | correctness |
| enum-and-guards State machine | a tangle of boolean flags | each state names its legal buttons; a new flow is one case, checkable alone | extensibility / testability |
| retain the card after 3 strikes | just reject and let them retry | the likeliest holder of a thrice-wrong card isn't you — the lockout is the safe default | correctness |
Growth — when one ATM isn't enough. The first knobs are denominations (load the cassette with ₹2,000s and plan adapts for free) and transaction types (deposits, transfers — new states). Beyond a single kiosk you don't make this machine cleverer — you push truth outward: a daily limit is the bank's fact (many ATMs, one limit), and a crash between debit and commit is reconciled by a journal the bank replays overnight (the callout above). Same instinct throughout — keep the kiosk dumb and disciplined; let headquarters hold the truth.
The complete implementation
package dev.fiveyear.atm;
/** The radio to headquarters — the ATM's only source of truth about money. */
public interface BankService {
boolean validatePin(String cardNumber, String pin);
long balance(String cardNumber);
/** Throws IllegalStateException if the account can't cover the amount. */
void debit(String cardNumber, long amount);
}package dev.fiveyear.atm;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
public final class CashCassette {
private final TreeMap<Integer, Integer> notes = new TreeMap<>(Comparator.reverseOrder());
public void load(int denomination, int count) {
if (denomination <= 0 || count <= 0) {
throw new IllegalArgumentException("load real money");
}
notes.merge(denomination, count, Integer::sum);
}
public Optional<Map<Integer, Integer>> plan(long amount) {
Map<Integer, Integer> plan = new LinkedHashMap<>();
long remaining = amount;
for (Map.Entry<Integer, Integer> shelf : notes.entrySet()) {
int denom = shelf.getKey();
int use = (int) Math.min(remaining / denom, shelf.getValue());
if (use > 0) {
plan.put(denom, use);
remaining -= (long) use * denom;
}
}
return remaining == 0 ? Optional.of(plan) : Optional.empty();
}
public void commit(Map<Integer, Integer> plan) {
plan.forEach((denom, count) -> notes.merge(denom, -count, Integer::sum));
}
public long totalCash() {
return notes.entrySet().stream()
.mapToLong(e -> (long) e.getKey() * e.getValue())
.sum();
}
}package dev.fiveyear.atm;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
public final class Atm {
public enum State { IDLE, AWAITING_PIN, AUTHENTICATED }
private final BankService bank;
private final CashCassette cassette;
private final Set<String> retainedCards = new HashSet<>();
private State state = State.IDLE;
private String cardNumber;
private int pinAttempts;
public Atm(BankService bank, CashCassette cassette) {
this.bank = bank;
this.cassette = cassette;
}
public synchronized void insertCard(String cardNumber) {
requireState(State.IDLE);
this.cardNumber = cardNumber;
this.pinAttempts = 0;
state = State.AWAITING_PIN;
}
public synchronized boolean enterPin(String pin) {
requireState(State.AWAITING_PIN);
if (bank.validatePin(cardNumber, pin)) {
state = State.AUTHENTICATED;
pinAttempts = 0;
return true;
}
pinAttempts++;
if (pinAttempts >= 3) {
retainedCards.add(cardNumber);
cardNumber = null;
state = State.IDLE;
throw new IllegalStateException("card retained — contact your bank");
}
return false;
}
public synchronized long checkBalance() {
requireState(State.AUTHENTICATED);
return bank.balance(cardNumber);
}
/** Plan the notes, debit the bank, only then hand over cash. */
public synchronized Map<Integer, Integer> withdraw(long amount) {
requireState(State.AUTHENTICATED);
if (amount <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
Map<Integer, Integer> plan = cassette.plan(amount)
.orElseThrow(() -> new IllegalStateException(
"this machine can't dispense " + amount));
bank.debit(cardNumber, amount);
cassette.commit(plan);
return plan;
}
public synchronized void ejectCard() {
if (state == State.IDLE) {
throw new IllegalStateException("no card to eject");
}
cardNumber = null;
state = State.IDLE;
}
public synchronized State state() {
return state;
}
public synchronized boolean isRetained(String card) {
return retainedCards.contains(card);
}
private void requireState(State expected) {
if (state != expected) {
throw new IllegalStateException("can't do that while " + state);
}
}
}A visit to the kiosk:
// the test bank: a HashMap wearing the radio
BankService bank = new InMemoryBank(Map.of("7042", new Account("4321", 10_000L)));
CashCassette cassette = new CashCassette();
cassette.load(500, 10);
cassette.load(200, 5);
cassette.load(100, 5);
Atm atm = new Atm(bank, cassette);
atm.insertCard("7042");
atm.enterPin("0000"); // false — strike one
atm.enterPin("4321"); // true — AUTHENTICATED
atm.checkBalance(); // 10000, straight from the bank
Map<Integer, Integer> notes = atm.withdraw(2_700);
// notes = {500: 5, 200: 1} — planned first, debited second, dispensed third
atm.ejectCard();The interview corner
Clarify before you code: Who authenticates — this machine, the ATM network, or the bank? Are daily limits in scope, and whose fact are they? Deposits too, or withdrawals only?
The follow-up ladder:
- "Power dies between debit and dispense." The reconciliation journal: write intent before acting; the bank reverses unmatched debits overnight. Naming it is the senior answer.
- "Add deposits." The two-phase flow reversed — accept the envelope, credit only after verification; the money sits in a pending state on the bank's side, not the machine's.
- "Greedy can't compose every amount." Same upgrade as the vending machine: a small DP over note counts in
plan. - "Where does the PIN live?" Nowhere in this machine — verification happens at the bank; in transit it's an encrypted PIN block (name the HSM and move on).
- "Enforce a daily withdrawal limit." A bank-side fact — many ATMs, one limit — the machine just relays the refusal. Putting it in the kiosk is the trap.
Mistakes that fail the round: dispensing before the debit; caching balances in the machine; retrying a timed-out debit without an idempotency key (the double-debit dispute generator).
Where to go from here — and Phase 1, closed
Pocket version: the ATM is a guard, not a banker — states gate the buttons, three strikes eat the card, and money moves in plan → debit → commit order, so the two pots of truth can never disagree.
That's nine machines and games, and the Phase 1 toolkit is complete: tiny casts, state machines, twins collapsed into maps, injected randomness and dependencies, data over if-chains, one-validator designs, Strategy where behavior truly varies — and refusals always before anything irreversible.
- Add a daily withdrawal limit — one more gate in
withdraw, one more fact the bank owns (not the machine — multiple ATMs, one limit). - Add the reconciliation journal from the callout — then you've designed a real ATM.
- Phase 2 starts with the backlog's systems-and-libraries run: a logging library, a JSON parser, a file system — same recipe, new terrain. And if you're new here, the recipe lives in A Rookie's Guide to LLD.
Next time a machine swallows your card, you'll know: somewhere inside, pinAttempts hit three, and a very disciplined security guard did exactly what the procedure said.