Skip to content

Low Level DesignWorked problems

Design an ATM

Design an ATM step by step for a low level design interview. Grow from a card and a bank call to a session state machine, note planning that greedy gets wrong, a hold-dispense-capture protocol that survives a power cut, and a bank that two ATMs can't overdraw, with code in Java, Python, C#, Go and TypeScript.

Foundation30 min core · 45 min full depth

“Design an ATM.” Sometimes with “focus on the withdrawal”, which is the interviewer telling you where the interesting part is.

An ATM looks like a state machine question, and most answers treat it as one: card inserted, PIN entered, transaction chosen, card ejected, with a class for each step. That part takes ten minutes, and it’s the least interesting thing about an ATM.

The real question is what happens to the customer’s money when something fails halfway through a withdrawal. The notes jam. The network drops. The power goes out with the cash halfway out of the slot. Who loses, and how do we make sure nobody does?

If you only have a minute, this is the design. The rest of the page explains how we get here.

  1. The bank owns the money, not the ATM. Balances and PIN checks live at the bank, behind an interface the ATM calls over the network. The ATM only knows what’s physically inside it.
  2. A session tracks where the customer is. Idle, card inserted, PIN accepted. Each action is allowed in exactly one of these, and three wrong PINs keep the card.
  3. The notes are planned before the bank is asked. If the machine can’t make 130 from the notes it has, the customer hears “choose another amount” and the bank never hears about it. The plan tries smaller notes when the biggest ones get stuck.
  4. A withdrawal is hold, dispense, then capture. The bank first holds the money, the notes come out, and only then is the hold turned into a real charge (captured). If the notes jam, the hold is released. Each step is written to a journal before it happens, so even a power cut can be settled on restart.
  5. The race is at the bank. Two cards on one account, at two ATMs, can’t both spend the last 100, because the bank checks and holds in one step.

We’ll build this in four stages, then deal with two ATMs drawing on one account at once.

Before drawing anything, we ask a few questions. Each answer changes a class. Anything that doesn’t, we assume and move on.

Interviewerminute 0

Design an ATM.

What they are testing

Whether you focus on the withdrawal and its failures within about three minutes, and ask what the ATM owns versus the bank. Spending five minutes on deposits, transfers and receipts before designing anything looks like covering breadth to avoid depth.

Here are the questions that matter:

  • Which operations? Withdraw and check the balance. Deposits come later as an extension.
  • Does the ATM keep balances? No. The bank does, and the ATM talks to it over a network. An ATM that worked offline would need its own limits and a way to settle up later.
  • Which notes? A few kinds, like 20s and 50s, each in its own box called a cassette. With only one kind of note, planning would be a simple division.
  • What happens when the cash jams, or the power goes? Neither the customer nor the bank may lose money. This is the real question, and it shapes stage 4.
  • Can one account have several cards? Yes, joint accounts exist. So two withdrawals on one account can happen at the same moment.

These answers give us one promise that matters more than the others: no failure loses money for either side. A jam means no charge. Cash out means a charge. A power cut at any moment leaves enough on record to settle it. Three smaller promises come with it. A request repeated after a timeout never charges twice. Two withdrawals on one account can’t both spend the same money. And the ATM never stores or checks a PIN itself.

We leave out deposits, transfers, receipts and the screens.

Go deeperThree more questions, and what each changes1 min
Question We assume If the answer were different
Daily withdrawal limits? Enforced by the bank Enforced at the ATM, they’d be per machine, which is easy to get around
Keeping the card? After three wrong PINs Some banks block the card at the bank instead, and return it
Several currencies? One Each currency is its own set of cassettes and plans

We’ll build the design in four stages. Each stage adds only the classes that a new requirement needs. In each diagram, the new parts are outlined, and the colors show what each class does (the key is under every diagram).

Interviewerminute 4

Start with the happy path. A customer takes out 100. What are your classes?

What they are testing

Whether the bank is an interface the ATM calls, not a class holding balances, within about two minutes. An Account class with a balance field inside the ATM is the answer they hear most, and it means the ATM thinks it owns the money.

The first decision is what the ATM owns. It doesn’t own balances or PINs. The bank does, across a network, and every call to it can be slow or fail. So Bank is an interface the ATM calls. The ATM’s own model is only what’s physically in the machine: the card in the reader, and the cash slot. Card is a small value holding the number read from the card’s chip.

Stage 1: the ATM, and the bank behind an interface

Atm

-card: Card

+insertCard(Card)

+enterPin(pin) : bool

+withdraw(amount) : bool

+ejectCard()

«interface»

Bank

+verifyPin(Card, pin) : bool

+balance(Card) : long

+withdraw(Card, amount) : bool

«interface»

CashSlot

+dispense(amount) : bool

«value»

Card

+number

Atm

-card: Card

+insertCard(Card)

+enterPin(pin) : bool

+withdraw(amount) : bool

+ejectCard()

«interface»

Bank

+verifyPin(Card, pin) : bool

+balance(Card) : long

+withdraw(Card, amount) : bool

«interface»

CashSlot

+dispense(amount) : bool

«value»

Card

+number

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Atm.javaatm.pyAtm.csatm.goatm.ts
// What the ATM reads from the card's chip or stripe. The ATM never knows the balance or the
// PIN; both live at the bank, which is why the bank is an interface the ATM talks to.
record Card(String number) {}
// The bank as the ATM sees it. Behind it is a network and a core banking system the ATM
// doesn't control, and that can fail or be slow at any call.
interface Bank {
boolean verifyPin(Card card, String pin);
long balance(Card card);
boolean withdraw(Card card, long amount); // debits the account, or refuses
}
// The cash hardware: a motor that pushes notes out of the slot.
interface CashSlot {
boolean dispense(long amount); // false if the notes jammed
}
class Atm {
private final Bank bank;
private final CashSlot slot;
private Card card; // the card in the reader, or null
Atm(Bank bank, CashSlot slot) { this.bank = bank; this.slot = slot; }
void insertCard(Card c) { card = c; }
boolean enterPin(String pin) { return bank.verifyPin(card, pin); }
// flaw 1: nothing remembers whether the PIN was right. withdraw() works straight after
// insertCard(), or with no card at all. Stage 2 adds a session with states.
boolean withdraw(long amount) {
// flaw 2: the account is debited before any cash comes out. If the notes jam, the
// customer has paid for nothing. Stage 4 fixes the order with a hold.
if (!bank.withdraw(card, amount)) return false;
// flaw 3: "dispense 130" means nothing to a machine full of 20s and 50s. Stage 3
// plans the notes before asking the bank.
return slot.dispense(amount);
}
void ejectCard() { card = null; }
}

It works for the happy path, but it has three problems, and we leave them in on purpose. Nothing remembers whether the PIN was right, so withdraw works without one. The bank takes the money before the cash comes out, so a jam costs the customer. And “dispense 100” means nothing to a machine full of 20s and 50s. The next three stages fix these, one each.

Interviewerminute 8

What stops someone withdrawing before entering a PIN? And what about a stolen card?

What they are testing

Whether every operation checks one state field first, and whether the count of PIN attempts lives in the session. They also listen for whether you’d use the State pattern and why, or why not. Either answer is fine if you give a reason.

The session has three states: idle, card inserted, and authenticated (PIN accepted). Each operation is allowed in exactly one state, and checks that first. A call in the wrong state returns a named answer, like WRONG_STATE, instead of throwing an error. A customer pressing buttons in the wrong order is normal, not a bug.

Three wrong PINs end the session and keep the card. The count belongs to the session, so a new card starts at zero. The ATM never stores or checks the PIN: it passes it to the bank and forgets it. One method, endSession, clears everything, so no path can leave a card number behind for the next customer.

Stage 2: the session's states

insertCard

enterPin (correct)

enterPin (wrong, tries left)

third wrong PIN, card kept

ejectCard

withdraw

ejectCard

Idle

CardInserted

Authenticated

insertCard

enterPin (correct)

enterPin (wrong, tries left)

third wrong PIN, card kept

ejectCard

withdraw

ejectCard

Idle

CardInserted

Authenticated

Scroll or pinch to zoom, drag to pan
  • Color key
  • State
Stage 2: the ATM tracks its state

returns

Atm

-state: AtmState

-card: Card

-pinTries: int

+insertCard(Card) : Result

+enterPin(pin) : Result

+withdraw(amount) : Result

+ejectCard() : Result

«enum»

AtmState

IDLE

CARD_INSERTED

AUTHENTICATED

«enum»

Result

OK

WRONG_STATE

WRONG_PIN

CARD_RETAINED

DECLINED

DISPENSE_FAILED

«interface»

Bank

returns

Atm

-state: AtmState

-card: Card

-pinTries: int

+insertCard(Card) : Result

+enterPin(pin) : Result

+withdraw(amount) : Result

+ejectCard() : Result

«enum»

AtmState

IDLE

CARD_INSERTED

AUTHENTICATED

«enum»

Result

OK

WRONG_STATE

WRONG_PIN

CARD_RETAINED

DECLINED

DISPENSE_FAILED

«interface»

Bank

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Atm.javaatm.pyAtm.csatm.goatm.ts
// Where the session stands. Every operation is legal in exactly one of these.
enum AtmState { IDLE, CARD_INSERTED, AUTHENTICATED }
// Every way an operation can end. A wrong PIN or an operation in the wrong state is
// normal customer behavior, so it is an answer the screen shows, not an exception.
enum Result { OK, WRONG_STATE, WRONG_PIN, CARD_RETAINED, DECLINED, DISPENSE_FAILED }
class Atm {
static final int MAX_PIN_TRIES = 3;
private final Bank bank;
private final CashSlot slot;
private AtmState state = AtmState.IDLE;
private Card card;
private int pinTries;
Atm(Bank bank, CashSlot slot) { this.bank = bank; this.slot = slot; }
Result insertCard(Card c) {
if (state != AtmState.IDLE) return Result.WRONG_STATE;
card = c;
pinTries = 0;
state = AtmState.CARD_INSERTED;
return Result.OK;
}
Result enterPin(String pin) {
if (state != AtmState.CARD_INSERTED) return Result.WRONG_STATE;
// The PIN goes to the bank and is forgotten. The ATM never stores or compares it.
if (bank.verifyPin(card, pin)) {
state = AtmState.AUTHENTICATED;
return Result.OK;
}
// Three wrong tries and the card stays in the machine, so a stolen card can't be
// guessed at. The count lives in the session: a new card starts again at zero.
if (++pinTries >= MAX_PIN_TRIES) {
endSession();
return Result.CARD_RETAINED;
}
return Result.WRONG_PIN;
}
Result withdraw(long amount) {
if (state != AtmState.AUTHENTICATED) return Result.WRONG_STATE;
if (!bank.withdraw(card, amount)) return Result.DECLINED;
return slot.dispense(amount) ? Result.OK : Result.DISPENSE_FAILED; // still flaw 2
}
Result ejectCard() {
if (state == AtmState.IDLE) return Result.WRONG_STATE;
endSession();
return Result.OK;
}
// One place that clears the session, so no path can leave a card number behind.
private void endSession() {
card = null;
pinTries = 0;
state = AtmState.IDLE;
}
AtmState state() { return state; }
}
Go deeperWhy an enum and a check in each method, not the State pattern?1 min

Three states and five operations fit in one class you can read top to bottom. The vending machine uses full State classes, because each of its states changes what most events mean. Here, each operation is simply allowed in one state and refused in the others. If the session grows (a menu, receipts, deposits with their own steps), switching to the State pattern is easy, because every check is already the first line of its method.

Go deeperWhy does the bank check the PIN, not the ATM?1 min

Because the ATM stands in a public place and can be opened, tapped or replaced. A PIN that the ATM could check would have to be stored somewhere an attacker can reach. In real networks, the PIN pad encrypts the PIN the moment it’s typed, and only a special secure device at the bank (a hardware security module) can decrypt and compare it. The ATM never sees the PIN in plain form.

Interviewerminute 13

The machine has 20s and 50s, and some cassettes are running low. A customer asks for 130. What comes out?

What they are testing

Whether you plan the notes before asking the bank, and whether you know that “largest note first” can fail when notes are limited. Chain of Responsibility over the notes is the answer most resources teach. It’s the same greedy idea, and they’ll give you a case where it gets stuck.

A CashDispenser knows how many notes of each kind are left. Its job is to find a set of notes that adds up to the amount exactly. Two rules come out of this stage.

Plan before asking the bank. If the machine can’t make the amount, the answer is “choose another amount”, and the bank never hears of it. Asking the bank first, and then finding the notes don’t add up, means undoing a hold for nothing.

Largest-first is wrong here. The obvious method is greedy: take the largest note that fits, then the next, and so on. Let’s try 130 with plenty of 50s and 20s. Greedy takes two 50s, which makes 100. Now it needs 30, and no note makes 30. It’s stuck, but one 50 and four 20s pay 130 exactly.

Limited notes make it worse. Say the machine has one 500 and three 200s, and a customer asks for 600. Greedy takes the 500 and needs 100 more, which no note makes. Three 200s would have paid it.

So the planner tries largest first, but when it gets stuck, it backtracks: it puts one big note back and tries smaller ones. It also remembers which “amount left, from this cassette onward” can’t be made, so it never tries the same dead end twice. With a handful of note kinds, that’s fast.

Stage 3: the dispenser plans notes

makes

Atm

+withdraw(amount) : Result

CashDispenser

-cassettes: Map<int,int>

+plan(amount) : NotePlan

+dispense(NotePlan) : bool

«value»

NotePlan

+notes: Map<int,int>

+total() : long

«interface»

CashSlot

+dispense(NotePlan) : bool

«interface»

Bank

makes

Atm

+withdraw(amount) : Result

CashDispenser

-cassettes: Map<int,int>

+plan(amount) : NotePlan

+dispense(NotePlan) : bool

«value»

NotePlan

+notes: Map<int,int>

+total() : long

«interface»

CashSlot

+dispense(NotePlan) : bool

«interface»

Bank

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Atm.javaatm.pyAtm.cscash_dispenser.goatm.ts
// The notes for one withdrawal: denomination -> how many. A value, so a plan can be made,
// checked and logged before anything moves.
record NotePlan(Map<Integer, Integer> notes) {
long total() {
long t = 0;
for (var e : notes.entrySet()) t += (long) e.getKey() * e.getValue();
return t;
}
}
// The cash hardware. It is told exactly which notes to hand out, not just an amount.
interface CashSlot {
boolean dispense(NotePlan plan); // false if the notes jammed
}
// Knows what is in the cassettes and how to make an amount from it.
class CashDispenser {
// denomination -> notes left, largest first
private final TreeMap<Integer, Integer> cassettes = new TreeMap<>(Comparator.reverseOrder());
private final CashSlot slot;
CashDispenser(Map<Integer, Integer> loaded, CashSlot slot) {
cassettes.putAll(loaded);
this.slot = slot;
}
// Notes that make the amount exactly from what is loaded, preferring large notes, or empty.
// Plain greedy is wrong once a cassette runs low: 600 from one 500 and three 200s takes the
// 500 and is stuck at 100. So the search backtracks, and remembers dead ends so it stays
// fast: a few denominations times the amount, not every combination of notes.
Optional<NotePlan> plan(long amount) {
List<Integer> denoms = new ArrayList<>(cassettes.keySet());
Map<Integer, Integer> chosen = new LinkedHashMap<>();
return search(denoms, 0, amount, chosen, new HashSet<>())
? Optional.of(new NotePlan(Map.copyOf(chosen))) : Optional.empty();
}
// Tries the most notes of this denomination first, then one fewer, and so on. dead holds
// the (cassette, amount left) pairs already known to fail.
private boolean search(List<Integer> denoms, int i, long left, Map<Integer, Integer> chosen, Set<String> dead) {
if (left == 0) return true;
if (i == denoms.size() || dead.contains(i + ":" + left)) return false;
int d = denoms.get(i);
for (int n = (int) Math.min(cassettes.get(d), left / d); n >= 0; n--) {
if (n > 0) chosen.put(d, n); else chosen.remove(d);
if (search(denoms, i + 1, left - (long) n * d, chosen, dead)) return true;
}
chosen.remove(d);
dead.add(i + ":" + left); // from this cassette onward, this remainder can't be made
return false;
}
// Counts go down only after the notes are out. A jam leaves them unknown, which the
// operator settles at the next refill by counting the reject bin.
boolean dispense(NotePlan p) {
if (!slot.dispense(p)) return false;
p.notes().forEach((d, n) -> cassettes.merge(d, -n, Integer::sum));
return true;
}
}
// What each ATM action returns. CANNOT_DISPENSE means the machine can't make this amount from
// its notes.
enum Result { OK, WRONG_STATE, WRONG_PIN, CARD_RETAINED, CANNOT_DISPENSE, DECLINED, DISPENSE_FAILED }
class Atm {
static final int MAX_PIN_TRIES = 3;
private final Bank bank;
private final CashDispenser dispenser;
private AtmState state = AtmState.IDLE;
private Card card;
private int pinTries;
Atm(Bank bank, CashDispenser dispenser) { this.bank = bank; this.dispenser = dispenser; }
Result insertCard(Card c) {
if (state != AtmState.IDLE) return Result.WRONG_STATE;
card = c;
pinTries = 0;
state = AtmState.CARD_INSERTED;
return Result.OK;
}
Result enterPin(String pin) {
if (state != AtmState.CARD_INSERTED) return Result.WRONG_STATE;
if (bank.verifyPin(card, pin)) {
state = AtmState.AUTHENTICATED;
return Result.OK;
}
if (++pinTries >= MAX_PIN_TRIES) {
endSession();
return Result.CARD_RETAINED;
}
return Result.WRONG_PIN;
}
Result withdraw(long amount) {
if (state != AtmState.AUTHENTICATED) return Result.WRONG_STATE;
// Plan the notes first. Never take money for cash the machine can't hand over.
Optional<NotePlan> plan = dispenser.plan(amount);
if (plan.isEmpty()) return Result.CANNOT_DISPENSE;
if (!bank.withdraw(card, amount)) return Result.DECLINED;
// Still flaw 2: the account is already debited if this jams. Stage 4.
return dispenser.dispense(plan.get()) ? Result.OK : Result.DISPENSE_FAILED;
}
Result ejectCard() {
if (state == AtmState.IDLE) return Result.WRONG_STATE;
endSession();
return Result.OK;
}
private void endSession() {
card = null;
pinTries = 0;
state = AtmState.IDLE;
}
AtmState state() { return state; }
}

The cassette counts only go down after the hardware confirms the notes left the machine. After a jam, the count really is unknown (some notes may have gone to the reject bin), so the operator checks it at the next refill.

What does backtracking cost? A few more lines than a greedy loop. In return, it pays every amount the cassettes can make, and still prefers large notes, so customers get the usual mix.

Stage 4: A withdrawal that survives failure

Section titled “Stage 4: A withdrawal that survives failure”
Interviewerminute 19

The notes jam after the bank approved. Or the power goes out halfway through. What happens to the customer’s money?

What they are testing

Whether you split the bank call into a hold and a capture, put the dispense between them, and write down each step before taking it. “Wrap it in a transaction” is the warning sign: the cash dispenser can’t join a database transaction.

Here is the core problem. The bank’s records and the machine’s cash are two separate systems, and nothing can update both at the same instant. Whichever goes first, a failure between them leaves one side wrong. Charge first, and a jam costs the customer. Dispense first, and a lost network message costs the bank.

So the withdrawal becomes three bank calls, with the cash moving between the first and the last:

  1. Authorize a hold for the amount. The money is set aside, but not taken.
  2. Dispense the notes.
  3. Capture the hold if the notes came out, or reverse (release) it if they jammed.

Every call carries a transaction ID, a unique name for this withdrawal. So if a call times out and the ATM sends it again, the bank sees the same ID and gives the same answer, instead of placing a second hold. An action that’s safe to repeat like this is called idempotent.

That handles a jam. A power cut is harder, because the ATM forgets everything in its memory. So before each step, the ATM writes the step to a journal: a small log on storage that survives a restart. When the power comes back, recover() reads each transaction’s last step and finishes the job:

Last step written What it means What recovery does
STARTED A hold may or may not exist Reverse it (reversing an unknown ID does nothing)
DISPENSING The notes may be in the customer’s hand, or not Flag it for a person to settle
DISPENSED The customer has the cash Capture it
CAPTURED, REVERSED, DECLINED Already settled Nothing

DISPENSING is the honest row. If the power went while the motor was running, the machine can’t know where the notes are. Capturing could charge for cash never received. Reversing could give cash away. Neither guess is acceptable, so the transaction goes to a person, who counts the cassettes and the reject bin. Real ATMs do exactly this.

Go deeperA jam, step by step1 min

Here is a withdrawal where the notes jam: the hold is placed, the dispense fails, and the hold is reversed. The customer is never charged.

Sequence: a withdrawal, and a jam
CashDispenserBankJournalAtmCashDispenserBankJournalAtmalt[notes delivered][jam]plan(100)2 x 50t7 STARTEDauthorize(t7, card, 100)approved (hold placed)t7 DISPENSINGdispense(2 x 50)okt7 DISPENSEDcapture(t7)t7 CAPTUREDfailedreverse(t7)t7 REVERSEDCashDispenserBankJournalAtmCashDispenserBankJournalAtmalt[notes delivered][jam]plan(100)2 x 50t7 STARTEDauthorize(t7, card, 100)approved (hold placed)t7 DISPENSINGdispense(2 x 50)okt7 DISPENSEDcapture(t7)t7 CAPTUREDfailedreverse(t7)t7 REVERSED
Scroll or pinch to zoom, drag to pan
  • Color key
  • Entity
  • Interface
  • Facade
Stage 4: the bank's hold protocol and the journal

Atm

-journal: Journal

-ids: Supplier<String>

+withdraw(amount) : Result

+recover() : List<String>

«interface»

Bank

+verifyPin(Card, pin) : bool

+authorize(txnId, Card, amount) : bool

+capture(txnId)

+reverse(txnId)

«interface»

Journal

+record(txnId, Step)

+lastSteps() : Map<String,Step>

InMemoryJournal

«enum»

Step

STARTED

DECLINED

DISPENSING

DISPENSED

CAPTURED

REVERSED

CashDispenser

Atm

-journal: Journal

-ids: Supplier<String>

+withdraw(amount) : Result

+recover() : List<String>

«interface»

Bank

+verifyPin(Card, pin) : bool

+authorize(txnId, Card, amount) : bool

+capture(txnId)

+reverse(txnId)

«interface»

Journal

+record(txnId, Step)

+lastSteps() : Map<String,Step>

InMemoryJournal

«enum»

Step

STARTED

DECLINED

DISPENSING

DISPENSED

CAPTURED

REVERSED

CashDispenser

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Atm.javaatm.pyAtm.csatm.goatm.ts
// The bank's side of a withdrawal, in three calls. A hold reserves the money without moving
// it; capture makes it final; reverse releases it. Each call carries the transaction id, so a
// retry after a timeout repeats the same request instead of making a second one.
interface Bank {
boolean verifyPin(Card card, String pin);
boolean authorize(String txnId, Card card, long amount); // place a hold, or refuse
void capture(String txnId); // the cash is out: make it final
void reverse(String txnId); // no cash: release the hold
}
// How far a withdrawal got. The journal's last step for a transaction says what recovery owes.
enum Step { STARTED, DECLINED, DISPENSING, DISPENSED, CAPTURED, REVERSED }
// Written before each step is taken, to storage that survives a power cut. It is the ATM's
// memory of what it promised the bank, which RAM is not.
interface Journal {
void record(String txnId, Step step);
Map<String, Step> lastSteps(); // each transaction's latest step, oldest first
}
// Keeps the journal in memory, for tests. A real ATM writes it to storage that survives a power
// cut. Insertion order is kept, so recovery sees the oldest transaction first.
class InMemoryJournal implements Journal {
private final Map<String, Step> last = new LinkedHashMap<>();
public void record(String txnId, Step step) { last.put(txnId, step); }
public Map<String, Step> lastSteps() { return Collections.unmodifiableMap(last); }
}
// The machine a customer stands at. It moves through its states one call at a time: card, PIN,
// withdraw, eject. It never touches the account itself; it asks the bank, and writes each step
// to the journal first.
class Atm {
static final int MAX_PIN_TRIES = 3;
private final Bank bank;
private final CashDispenser dispenser;
private final Journal journal;
private final Supplier<String> ids; // unique transaction ids; injected so tests can name them
private AtmState state = AtmState.IDLE;
private Card card;
private int pinTries;
Atm(Bank bank, CashDispenser dispenser, Journal journal, Supplier<String> ids) {
this.bank = bank;
this.dispenser = dispenser;
this.journal = journal;
this.ids = ids;
}
Result insertCard(Card c) {
if (state != AtmState.IDLE) return Result.WRONG_STATE;
card = c;
pinTries = 0;
state = AtmState.CARD_INSERTED;
return Result.OK;
}
// Three wrong PINs in a row and the machine keeps the card, so a thief can't keep guessing.
Result enterPin(String pin) {
if (state != AtmState.CARD_INSERTED) return Result.WRONG_STATE;
if (bank.verifyPin(card, pin)) {
state = AtmState.AUTHENTICATED;
return Result.OK;
}
if (++pinTries >= MAX_PIN_TRIES) {
endSession();
return Result.CARD_RETAINED;
}
return Result.WRONG_PIN;
}
// Hold, dispense, then capture or reverse. Each step is journaled before it happens, so
// a power cut at any line leaves enough on record for recover() to finish the job.
Result withdraw(long amount) {
if (state != AtmState.AUTHENTICATED) return Result.WRONG_STATE;
Optional<NotePlan> plan = dispenser.plan(amount);
if (plan.isEmpty()) return Result.CANNOT_DISPENSE;
String txn = ids.get();
journal.record(txn, Step.STARTED); // before the bank hears of it
if (!bank.authorize(txn, card, amount)) {
journal.record(txn, Step.DECLINED);
return Result.DECLINED;
}
journal.record(txn, Step.DISPENSING);
if (!dispenser.dispense(plan.get())) {
bank.reverse(txn); // the notes never left: release the hold
journal.record(txn, Step.REVERSED);
return Result.DISPENSE_FAILED;
}
journal.record(txn, Step.DISPENSED);
bank.capture(txn);
journal.record(txn, Step.CAPTURED);
return Result.OK;
}
// Runs at power-up, before the first customer. Finishes or undoes whatever was in flight,
// and returns the transactions only a person counting the cash can settle.
List<String> recover() {
List<String> suspect = new ArrayList<>();
for (var e : new ArrayList<>(journal.lastSteps().entrySet())) {
String txn = e.getKey();
switch (e.getValue()) {
// A hold may or may not exist. Reversing an id the bank never saw does nothing,
// so reversing is always safe here.
case STARTED -> { bank.reverse(txn); journal.record(txn, Step.REVERSED); }
// The notes are out; the customer has the cash. Make the debit final.
case DISPENSED -> { bank.capture(txn); journal.record(txn, Step.CAPTURED); }
// The power went mid-dispense: the notes may be in the customer's hand or in
// the
// machine. Guessing either way is wrong for someone, so a person decides.
case DISPENSING -> suspect.add(txn);
default -> { } // DECLINED, CAPTURED, REVERSED: nothing is owed
}
}
return suspect;
}
Result ejectCard() {
if (state == AtmState.IDLE) return Result.WRONG_STATE;
endSession();
return Result.OK;
}
// Forget the card and the PIN count, ready for the next customer.
private void endSession() {
card = null;
pinTries = 0;
state = AtmState.IDLE;
}
AtmState state() { return state; }
}

What does it cost? Three bank calls instead of one, a journal write before each step, and a person to settle the rare flagged case. In return, every failure has a clear outcome. A jam costs the customer nothing. A power cut is settled on restart. A lost bank reply is retried with the same ID. Only the one truly unknowable case reaches a person.

Go deeperWhat the journal actually stores1 min

In our code, each journal entry is tiny: the transaction ID and the last step reached. A real machine also stores the amount and the note plan, so an operator can settle a flagged case. There’s no card number and no PIN, so a stolen journal reveals nothing useful.

The order of writing is the whole trick. Before calling authorize, the ATM writes STARTED. Before starting the motor, it writes DISPENSING. When the hardware confirms the notes left, it writes DISPENSED. Then it calls capture, and writes CAPTURED once the bank confirms. Each write must reach the storage before the next action starts. If the ATM wrote after acting, a power cut in between would leave the journal one step behind reality, and recovery would do the wrong thing.

Entries that reach a settled step (CAPTURED, REVERSED, DECLINED) can be deleted after the bank’s records are reconciled, usually at the end of the day. So the journal stays small.

Go deeperWhat if the bank never answers the authorize call?1 min

The ATM waits a set time, then tells the customer the withdrawal was declined. The journal still says STARTED, so the ATM sends a reversal for that ID, and keeps retrying until the bank confirms it. If the bank had placed the hold, the reversal releases it. If not, the reversal does nothing. Either way, no money is stuck, and the customer can simply try again, with a new transaction ID.

This is enough for a mid-level answer. The session has states, the notes are planned before the bank is called, and a jam doesn’t cost the customer. The rest of the page is what makes it a senior answer.

Here is everything together. Each class is here because a requirement needed it. The in-memory bank comes from the next section.

ATM: full class diagram

returns

makes

Atm

-state: AtmState

-card: Card

-pinTries: int

+insertCard(Card) : Result

+enterPin(pin) : Result

+withdraw(amount) : Result

+recover() : List<String>

+ejectCard() : Result

«interface»

Bank

+verifyPin(Card, pin) : bool

+authorize(txnId, Card, amount) : bool

+capture(txnId)

+reverse(txnId)

InMemoryBank

-accounts: Map

-holds: Map

-decisions: Map

CashDispenser

-cassettes: Map<int,int>

+plan(amount) : NotePlan

+dispense(NotePlan) : bool

«interface»

CashSlot

+dispense(NotePlan) : bool

«interface»

Journal

+record(txnId, Step)

+lastSteps()

InMemoryJournal

«value»

Card

+number

«value»

NotePlan

+notes

+total()

«enum»

AtmState

IDLE

CARD_INSERTED

AUTHENTICATED

«enum»

Step

STARTED

DECLINED

DISPENSING

DISPENSED

CAPTURED

REVERSED

«enum»

Result

OK

WRONG_STATE

WRONG_PIN

CARD_RETAINED

CANNOT_DISPENSE

DECLINED

DISPENSE_FAILED

returns

makes

Atm

-state: AtmState

-card: Card

-pinTries: int

+insertCard(Card) : Result

+enterPin(pin) : Result

+withdraw(amount) : Result

+recover() : List<String>

+ejectCard() : Result

«interface»

Bank

+verifyPin(Card, pin) : bool

+authorize(txnId, Card, amount) : bool

+capture(txnId)

+reverse(txnId)

InMemoryBank

-accounts: Map

-holds: Map

-decisions: Map

CashDispenser

-cassettes: Map<int,int>

+plan(amount) : NotePlan

+dispense(NotePlan) : bool

«interface»

CashSlot

+dispense(NotePlan) : bool

«interface»

Journal

+record(txnId, Step)

+lastSteps()

InMemoryJournal

«value»

Card

+number

«value»

NotePlan

+notes

+total()

«enum»

AtmState

IDLE

CARD_INSERTED

AUTHENTICATED

«enum»

Step

STARTED

DECLINED

DISPENSING

DISPENSED

CAPTURED

REVERSED

«enum»

Result

OK

WRONG_STATE

WRONG_PIN

CARD_RETAINED

CANNOT_DISPENSE

DECLINED

DISPENSE_FAILED

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
atm· read only
AllInOne.java
AllInOne.java
// The complete design, built from every stage on this page.
import java.util.*;
import java.util.concurrent.*;
import java.util.function.*;
// What the ATM reads from the card's chip or stripe. The ATM never knows the balance or the
// PIN; both live at the bank, which is why the bank is an interface the ATM talks to.
record Card(String number) {}
// The bank's side of a withdrawal, in three calls. A hold reserves the money without moving
// it; capture makes it final; reverse releases it. Each call carries the transaction id, so a
// retry after a timeout repeats the same request instead of making a second one.
interface Bank {
boolean verifyPin(Card card, String pin);
boolean authorize(String txnId, Card card, long amount); // place a hold, or refuse
void capture(String txnId); // the cash is out: make it final
void reverse(String txnId); // no cash: release the hold
}
// The cash hardware. It is told exactly which notes to hand out, not just an amount.
interface CashSlot {
boolean dispense(NotePlan plan); // false if the notes jammed
}
// The machine a customer stands at. It moves through its states one call at a time: card, PIN,
// withdraw, eject. It never touches the account itself; it asks the bank, and writes each step
// to the journal first.
class Atm {
static final int MAX_PIN_TRIES = 3;
private final Bank bank;
private final CashDispenser dispenser;
private final Journal journal;
private final Supplier<String> ids; // unique transaction ids; injected so tests can name them
private AtmState state = AtmState.IDLE;
private Card card;
private int pinTries;
Atm(Bank bank, CashDispenser dispenser, Journal journal, Supplier<String> ids) {
this.bank = bank;
this.dispenser = dispenser;
this.journal = journal;
this.ids = ids;
}
Result insertCard(Card c) {
if (state != AtmState.IDLE) return Result.WRONG_STATE;
card = c;
pinTries = 0;
state = AtmState.CARD_INSERTED;
return Result.OK;
}
// Three wrong PINs in a row and the machine keeps the card, so a thief can't keep guessing.
Result enterPin(String pin) {
if (state != AtmState.CARD_INSERTED) return Result.WRONG_STATE;
if (bank.verifyPin(card, pin)) {
state = AtmState.AUTHENTICATED;
return Result.OK;
}
if (++pinTries >= MAX_PIN_TRIES) {
endSession();
return Result.CARD_RETAINED;
}
return Result.WRONG_PIN;
}
// Hold, dispense, then capture or reverse. Each step is journaled before it happens, so
// a power cut at any line leaves enough on record for recover() to finish the job.
Result withdraw(long amount) {
if (state != AtmState.AUTHENTICATED) return Result.WRONG_STATE;
Optional<NotePlan> plan = dispenser.plan(amount);
if (plan.isEmpty()) return Result.CANNOT_DISPENSE;
String txn = ids.get();
journal.record(txn, Step.STARTED); // before the bank hears of it
if (!bank.authorize(txn, card, amount)) {
journal.record(txn, Step.DECLINED);
return Result.DECLINED;
}
journal.record(txn, Step.DISPENSING);
if (!dispenser.dispense(plan.get())) {
bank.reverse(txn); // the notes never left: release the hold
journal.record(txn, Step.REVERSED);
return Result.DISPENSE_FAILED;
}
journal.record(txn, Step.DISPENSED);
bank.capture(txn);
journal.record(txn, Step.CAPTURED);
return Result.OK;
}
// Runs at power-up, before the first customer. Finishes or undoes whatever was in flight,
// and returns the transactions only a person counting the cash can settle.
List<String> recover() {
List<String> suspect = new ArrayList<>();
for (var e : new ArrayList<>(journal.lastSteps().entrySet())) {
String txn = e.getKey();
switch (e.getValue()) {
// A hold may or may not exist. Reversing an id the bank never saw does nothing,
// so reversing is always safe here.
case STARTED -> { bank.reverse(txn); journal.record(txn, Step.REVERSED); }
// The notes are out; the customer has the cash. Make the debit final.
case DISPENSED -> { bank.capture(txn); journal.record(txn, Step.CAPTURED); }
// The power went mid-dispense: the notes may be in the customer's hand or in
// the
// machine. Guessing either way is wrong for someone, so a person decides.
case DISPENSING -> suspect.add(txn);
default -> { } // DECLINED, CAPTURED, REVERSED: nothing is owed
}
}
return suspect;
}
Result ejectCard() {
if (state == AtmState.IDLE) return Result.WRONG_STATE;
endSession();
return Result.OK;
}
// Forget the card and the PIN count, ready for the next customer.
private void endSession() {
card = null;
pinTries = 0;
state = AtmState.IDLE;
}
AtmState state() { return state; }
}
// Where the session stands. Every operation is legal in exactly one of these.
enum AtmState { IDLE, CARD_INSERTED, AUTHENTICATED }
// What each ATM action returns. CANNOT_DISPENSE means the machine can't make this amount from
// its notes.
enum Result { OK, WRONG_STATE, WRONG_PIN, CARD_RETAINED, CANNOT_DISPENSE, DECLINED, DISPENSE_FAILED }
// The notes for one withdrawal: denomination -> how many. A value, so a plan can be made,
// checked and logged before anything moves.
record NotePlan(Map<Integer, Integer> notes) {
long total() {
long t = 0;
for (var e : notes.entrySet()) t += (long) e.getKey() * e.getValue();
return t;
}
}
// Knows what is in the cassettes and how to make an amount from it.
class CashDispenser {
// denomination -> notes left, largest first
private final TreeMap<Integer, Integer> cassettes = new TreeMap<>(Comparator.reverseOrder());
private final CashSlot slot;
CashDispenser(Map<Integer, Integer> loaded, CashSlot slot) {
cassettes.putAll(loaded);
this.slot = slot;
}
// Notes that make the amount exactly from what is loaded, preferring large notes, or empty.
// Plain greedy is wrong once a cassette runs low: 600 from one 500 and three 200s takes the
// 500 and is stuck at 100. So the search backtracks, and remembers dead ends so it stays
// fast: a few denominations times the amount, not every combination of notes.
Optional<NotePlan> plan(long amount) {
List<Integer> denoms = new ArrayList<>(cassettes.keySet());
Map<Integer, Integer> chosen = new LinkedHashMap<>();
return search(denoms, 0, amount, chosen, new HashSet<>())
? Optional.of(new NotePlan(Map.copyOf(chosen))) : Optional.empty();
}
// Tries the most notes of this denomination first, then one fewer, and so on. dead holds
// the (cassette, amount left) pairs already known to fail.
private boolean search(List<Integer> denoms, int i, long left, Map<Integer, Integer> chosen, Set<String> dead) {
if (left == 0) return true;
if (i == denoms.size() || dead.contains(i + ":" + left)) return false;
int d = denoms.get(i);
for (int n = (int) Math.min(cassettes.get(d), left / d); n >= 0; n--) {
if (n > 0) chosen.put(d, n); else chosen.remove(d);
if (search(denoms, i + 1, left - (long) n * d, chosen, dead)) return true;
}
chosen.remove(d);
dead.add(i + ":" + left); // from this cassette onward, this remainder can't be made
return false;
}
// Counts go down only after the notes are out. A jam leaves them unknown, which the
// operator settles at the next refill by counting the reject bin.
boolean dispense(NotePlan p) {
if (!slot.dispense(p)) return false;
p.notes().forEach((d, n) -> cassettes.merge(d, -n, Integer::sum));
return true;
}
}
// How far a withdrawal got. The journal's last step for a transaction says what recovery owes.
enum Step { STARTED, DECLINED, DISPENSING, DISPENSED, CAPTURED, REVERSED }
// Written before each step is taken, to storage that survives a power cut. It is the ATM's
// memory of what it promised the bank, which RAM is not.
interface Journal {
void record(String txnId, Step step);
Map<String, Step> lastSteps(); // each transaction's latest step, oldest first
}
// Keeps the journal in memory, for tests. A real ATM writes it to storage that survives a power
// cut. Insertion order is kept, so recovery sees the oldest transaction first.
class InMemoryJournal implements Journal {
private final Map<String, Step> last = new LinkedHashMap<>();
public void record(String txnId, Step step) { last.put(txnId, step); }
public Map<String, Step> lastSteps() { return Collections.unmodifiableMap(last); }
}
// The bank side, where the real race is: two ATMs, one joint account, the last 100.
// Each ATM serves one customer at a time; the account is what they share.
class InMemoryBank implements Bank {
// One customer account. held is money promised to withdrawals that haven't finished, so
// what can be spent is balance minus held.
private static final class Account {
final String pin;
long balance;
long held; // reserved by authorized withdrawals that are not captured yet
Account(String pin, long balance) { this.pin = pin; this.balance = balance; }
}
// A hold: which account and how much, so capture and reverse know what to settle.
private record Hold(Account account, long amount) {}
private final Map<String, Account> accounts = new ConcurrentHashMap<>();
private final Map<String, Hold> holds = new ConcurrentHashMap<>();
private final Map<String, Boolean> decisions = new ConcurrentHashMap<>(); // txn id -> first answer
// Sets up an account. Tests call it; a real bank already has its accounts.
void open(Card card, String pin, long balance) { accounts.put(card.number(), new Account(pin, balance)); }
public boolean verifyPin(Card card, String pin) {
Account a = accounts.get(card.number());
return a != null && a.pin.equals(pin); // a real bank compares a hash inside an HSM
}
public boolean authorize(String txnId, Card card, long amount) {
Account a = accounts.get(card.number());
if (a == null) return false;
// One lock per account: "is there enough?" and "hold it" are one step, so two ATMs
// can't both see 100 available and both hold it. Other accounts never wait on this one.
synchronized (a) {
Boolean earlier = decisions.get(txnId);
if (earlier != null) return earlier; // a retry gets the first answer, not a second hold
boolean ok = a.balance - a.held >= amount;
if (ok) {
a.held += amount;
holds.put(txnId, new Hold(a, amount));
}
decisions.put(txnId, ok);
return ok;
}
}
// capture and reverse both start by removing the hold. ConcurrentHashMap.remove is atomic,
// so exactly one of them, once, gets it. A second capture, or a reverse after a capture,
// finds nothing and does nothing: both are safe to retry.
public void capture(String txnId) {
Hold h = holds.remove(txnId);
if (h == null) return;
synchronized (h.account()) {
h.account().held -= h.amount();
h.account().balance -= h.amount();
}
}
public void reverse(String txnId) {
Hold h = holds.remove(txnId);
if (h == null) return;
synchronized (h.account()) { h.account().held -= h.amount(); }
}
// What can be spent right now: the balance minus what is already held.
long available(Card card) {
Account a = accounts.get(card.number());
synchronized (a) { return a.balance - a.held; }
}
}
Read onlyJava 21Every file builds in CI13 files281 lines
all_in_one.py
all_in_one.py
Read onlyPython 3.12Every file builds in CI15 files293 lines
AllInOne.cs
AllInOne.cs
Read onlyC# · .NET 10Every file builds in CI13 files283 lines
all_in_one.go
all_in_one.go
Read onlyGo 1.24Every file builds in CI15 files397 lines
all-in-one.ts
all-in-one.ts
Read onlyTypeScript, strictEvery file builds in CI14 files276 lines

Three ideas hold it together:

  • Each system owns its own truth. The bank owns money, the dispenser owns cash, and the journal owns what the ATM promised. Atm coordinates them and keeps none of their data. To the screen, Atm is a simple front door with five methods (a facade).
  • Every step is written down before it’s taken. That’s what makes a power cut at any moment recoverable. Writing intent before acting is called a write-ahead log. The journal is an interface: flash storage in the machine, a simple map in tests.
  • Every bank call is safe to repeat. Transaction IDs make authorize, capture and reverse idempotent, so retries never move money twice. The bank, the cash slot and the journal are all interfaces, with real versions in the machine and fakes in tests.

Each ATM serves one customer at a time, so a single machine has little to race on. But a bank has thousands of ATMs, and they all talk to the same accounts.

Interviewerminute 27

A couple share an account with 100 in it. They each withdraw 100, at different ATMs, at the same second. What happens?

What they are testing

Whether you see that the race is at the bank, not in the ATM, and that “check the balance, then hold” must be one step. Locking inside the ATM is the answer they expect to correct: the two ATMs share nothing but the account.

What two ATMs share is the account. Suppose the bank checks “is 100 available?” for both requests before holding money for either. Both see 100. Both place a hold. The account is now overdrawn by 100.

So “check that enough is available, and hold it” must be one step that no other request can split.

The clearly correct fix is one lock for the whole bank. Every authorize takes it. That’s safe, but every account now waits for every other account.

The better fix is a lock per account. The check and the hold happen under that account’s own lock. Customers of different accounts never wait for each other. In a real bank with a database, the same idea is one conditional update: UPDATE ... SET held = held + :a WHERE balance - held >= :a. If no row changed, the request is declined.

Option How Correct? Cost
1. One lock for the whole bank Every authorize takes it Yes Every account waits for every other
2. A lock per account (in memory) Check and hold under that account’s lock Yes Only works inside one process
3. A conditional update in the database One statement checks and holds Yes The real answer once the bank has many servers
4. Read the balance, then write Two separate statements No: the race above None, which is why it ships by mistake

Here is the race with the fix in place: both ATMs ask at once, the account lock lets one through first, and the second finds nothing left.

Sequence: two ATMs, one account, the last 100
Bank (account lock)ATM 2ATM 1Bank (account lock)ATM 2ATM 1t1 holds the account lock, t2 waitst2 gets the lock: available is 0authorize(t1, card A, 100)authorize(t2, card B, 100)approved, held 100declinedBank (account lock)ATM 2ATM 1Bank (account lock)ATM 2ATM 1t1 holds the account lock, t2 waitst2 gets the lock: available is 0authorize(t1, card A, 100)authorize(t2, card B, 100)approved, held 100declined
Scroll or pinch to zoom, drag to pan
  • Color key
  • Interface
InMemoryBank.javain_memory_bank.pyInMemoryBank.csmemory_bank.goin-memory-bank.ts
// The bank side, where the real race is: two ATMs, one joint account, the last 100.
// Each ATM serves one customer at a time; the account is what they share.
class InMemoryBank implements Bank {
// One customer account. held is money promised to withdrawals that haven't finished, so
// what can be spent is balance minus held.
private static final class Account {
final String pin;
long balance;
long held; // reserved by authorized withdrawals that are not captured yet
Account(String pin, long balance) { this.pin = pin; this.balance = balance; }
}
// A hold: which account and how much, so capture and reverse know what to settle.
private record Hold(Account account, long amount) {}
private final Map<String, Account> accounts = new ConcurrentHashMap<>();
private final Map<String, Hold> holds = new ConcurrentHashMap<>();
private final Map<String, Boolean> decisions = new ConcurrentHashMap<>(); // txn id -> first answer
// Sets up an account. Tests call it; a real bank already has its accounts.
void open(Card card, String pin, long balance) { accounts.put(card.number(), new Account(pin, balance)); }
public boolean verifyPin(Card card, String pin) {
Account a = accounts.get(card.number());
return a != null && a.pin.equals(pin); // a real bank compares a hash inside an HSM
}
public boolean authorize(String txnId, Card card, long amount) {
Account a = accounts.get(card.number());
if (a == null) return false;
// One lock per account: "is there enough?" and "hold it" are one step, so two ATMs
// can't both see 100 available and both hold it. Other accounts never wait on this one.
synchronized (a) {
Boolean earlier = decisions.get(txnId);
if (earlier != null) return earlier; // a retry gets the first answer, not a second hold
boolean ok = a.balance - a.held >= amount;
if (ok) {
a.held += amount;
holds.put(txnId, new Hold(a, amount));
}
decisions.put(txnId, ok);
return ok;
}
}
// capture and reverse both start by removing the hold. ConcurrentHashMap.remove is atomic,
// so exactly one of them, once, gets it. A second capture, or a reverse after a capture,
// finds nothing and does nothing: both are safe to retry.
public void capture(String txnId) {
Hold h = holds.remove(txnId);
if (h == null) return;
synchronized (h.account()) {
h.account().held -= h.amount();
h.account().balance -= h.amount();
}
}
public void reverse(String txnId) {
Hold h = holds.remove(txnId);
if (h == null) return;
synchronized (h.account()) { h.account().held -= h.amount(); }
}
// What can be spent right now: the balance minus what is already held.
long available(Card card) {
Account a = accounts.get(card.number());
synchronized (a) { return a.balance - a.held; }
}
}

Why is this correct? The money available is balance - held, and it only changes under the account’s lock. An authorize reads it and adds to held without letting go of the lock, so no other authorize can read the old value in between. Capture and reverse both start by removing the hold from a thread-safe map, which happens in one step. So exactly one of them gets the hold, once. A capture after a reverse, or a second capture, finds nothing and does nothing.

The same lock also protects the bank’s record of answers it already gave. That’s what makes a repeated authorize return its first answer, instead of placing a second hold.

What does it cost? The in-memory locks only work in one process, which is why a real bank uses the database update. And the record of answers grows with every transaction, so a real bank deletes old IDs after the network’s retry window (days, not seconds).

Go deeperThe ATM's own threads1 min

Inside one ATM, the one real concurrency issue is hardware. The cancel button, the card reader and the dispenser each report on their own threads. The ATM handles that the way the vending machine does: every hardware event goes through one queue to one thread. So withdraw never runs at the same time as ejectCard.

Go deeperWhere does the daily withdrawal limit go?1 min

In the bank’s authorize, under the same account lock. The check becomes: “the money available covers it, and today’s holds plus captures plus this amount stay under the limit”. Putting the limit in the ATM would enforce it per machine, so a customer could take the limit from every ATM in town.

Every outside system is an interface, so every failure is a few lines of fake. The tests that matter:

  • Withdrawing before the PIN is refused, and the balance is untouched.
  • Three wrong PINs keep the card.
  • The note plan pays 600 from three 200s, where greedy takes the one 500 and gets stuck.
  • An amount the machine can’t make never reaches the bank.
  • A jam releases the hold: the customer pays nothing.
  • A successful withdrawal holds, then captures.
  • Recovery after a power cut reverses a bare hold, captures a dispensed one, and flags the one caught mid-dispense.
  • A repeated authorize places one hold.
  • Sixteen authorizations racing for the last 100 approve exactly one.
Go deeperThe tests in five languages2 min

The recovery test protects the main promise. It fills a journal the way a power cut at three different moments would leave it, restarts an ATM on it, and checks the account balance and the flagged list. If anyone reorders the journal writes in withdraw, a half-done state stops matching its recovery rule, and this test catches it.

FakeSlot.javafake_slot.pyFakeSlot.csworld.gofake-slot.ts
// Records what the cash slot was asked to do, and can be told to jam.
static class FakeSlot implements CashSlot {
boolean jam;
final List<NotePlan> delivered = new ArrayList<>();
public boolean dispense(NotePlan p) {
if (jam) return false;
delivered.add(p);
return true;
}
}
static final Card CARD = new Card("4000-0001");
InMemoryBank bank = new InMemoryBank();
FakeSlot slot = new FakeSlot();
InMemoryJournal journal = new InMemoryJournal();
int nextId = 0;
// Fifty 20s and twenty 50s; the account holds 500; the PIN is 1234.
Atm atm() {
bank.open(CARD, "1234", 500);
CashDispenser d = new CashDispenser(Map.of(50, 20, 20, 50), slot);
return new Atm(bank, d, journal, () -> "t" + nextId++);
}
Atm signedIn() {
Atm a = atm();
a.insertCard(CARD);
a.enterPin("1234");
return a;
}
@Test
void withdrawingBeforeThePinIsRefused() {
Atm a = atm();
a.insertCard(CARD);
assertEquals(Result.WRONG_STATE, a.withdraw(100));
assertEquals(500, bank.available(CARD));
}
@Test
void threeWrongPinsKeepTheCard() {
Atm a = atm();
a.insertCard(CARD);
assertEquals(Result.WRONG_PIN, a.enterPin("0000"));
assertEquals(Result.WRONG_PIN, a.enterPin("1111"));
assertEquals(Result.CARD_RETAINED, a.enterPin("2222"));
assertEquals(AtmState.IDLE, a.state());
}
@Test
void theNotePlanFindsChangeWhereGreedyGetsStuck() {
CashDispenser d = new CashDispenser(Map.of(500, 1, 200, 3), slot);
assertEquals(Map.of(200, 3), d.plan(600).orElseThrow().notes()); // greedy takes the 500, then is stuck
assertTrue(d.plan(100).isEmpty());
}
@Test
void anAmountTheMachineCantMakeNeverReachesTheBank() {
assertEquals(Result.CANNOT_DISPENSE, signedIn().withdraw(135)); // no mix of 20s and 50s makes 135
assertTrue(journal.lastSteps().isEmpty()); // no transaction was even started
}
@Test
void aJamReleasesTheHold() {
Atm a = signedIn();
slot.jam = true;
assertEquals(Result.DISPENSE_FAILED, a.withdraw(100));
assertEquals(500, bank.available(CARD)); // the customer paid nothing
assertEquals(Step.REVERSED, journal.lastSteps().get("t0"));
}
@Test
void aWithdrawalHoldsThenCaptures() {
assertEquals(Result.OK, signedIn().withdraw(120));
assertEquals(380, bank.available(CARD));
assertEquals(120, slot.delivered.get(0).total());
}
@Test
void recoveryFinishesWhatAPowerCutInterrupted() {
atm();
bank.authorize("held", CARD, 100); // the power went after the hold...
journal.record("held", Step.STARTED);
bank.authorize("paid", CARD, 50); // ...after the cash came out...
journal.record("paid", Step.DISPENSED);
journal.record("mid", Step.DISPENSING); // ...and in the middle of dispensing
Atm restarted = new Atm(bank, new CashDispenser(Map.of(20, 50), slot), journal, () -> "x");
assertEquals(List.of("mid"), restarted.recover()); // only the unknowable one goes to a person
assertEquals(450, bank.available(CARD)); // "held" released, "paid" captured
}
@Test
void aRetriedAuthorizeIsOneHold() {
atm();
assertTrue(bank.authorize("t9", CARD, 300));
assertTrue(bank.authorize("t9", CARD, 300)); // the response was lost; the ATM asks again
assertEquals(200, bank.available(CARD));
}
@Test
void twoAtmsRacingForTheLastMoneyOnlyOneWins() throws Exception {
bank.open(CARD, "1234", 100);
ExecutorService atms = Executors.newFixedThreadPool(16);
CountDownLatch start = new CountDownLatch(1);
List<Future<Boolean>> tries = new ArrayList<>();
for (int i = 0; i < 16; i++) {
String id = "race" + i;
tries.add(atms.submit(() -> { start.await(); return bank.authorize(id, CARD, 100); }));
}
start.countDown(); // release all sixteen at once
int approved = 0;
for (Future<Boolean> f : tries) if (f.get()) approved++;
atms.shutdown();
assertEquals(1, approved);
assertEquals(0, bank.available(CARD));
}
Go deeperEvery edge case, and where it is handled2 min
Case What happens Where
Withdraw before the PIN WRONG_STATE; the bank isn’t called Atm.withdraw
Third wrong PIN Card kept, session ends Atm.enterPin
Amount the notes can’t make CANNOT_DISPENSE; no transaction started CashDispenser.plan
Greedy would get stuck The planner backtracks CashDispenser.plan
Bank declines DECLINED, written to the journal Atm.withdraw
Notes jam Hold reversed; cassette count left for the operator Atm.withdraw
Bank times out on authorize Shown as declined; reversal sent until the bank confirms journal STARTED
Power cut before the hold Reversal on restart, which does nothing Atm.recover
Power cut after the cash came out Capture on restart Atm.recover
Power cut while dispensing Flagged for a person Atm.recover
Same authorize sent twice One hold; the second gets the first answer the bank’s answer record
Capture after reverse Nothing happens one-step hold removal
Two ATMs, one account, the last 100 One approved, one declined per-account lock
Customer doesn’t take the cash Notes pulled back after a timeout; treated like a jam an extension
Decision We chose We gave up
Who owns money The bank, behind an interface Nothing; the ATM can’t own it safely
Session An enum state, checked first in every operation The State pattern’s structure, until the session grows
Bad input Named answers, not exceptions A caller can ignore an answer
Note planning Backtracking that remembers dead ends, before the bank A few lines more than greedy
Withdrawal order Authorize a hold, dispense, then capture or reverse Three bank calls instead of one
Crash safety A journal written before each step; recovery at power-up A journal write per step, and a person for rare cases
Retries A transaction ID on every bank call The bank must remember IDs for the retry window
Concurrency Check and hold as one step per account, at the bank In-memory locks only work inside one process
Go deeperGenuinely different designs, and when to pick them2 min

The full State pattern. One class per session state (Idle, CardInserted, Authenticated, and in a fuller model SelectingTransaction, Dispensing, PrintingReceipt), each handling every operation. It pays off once the session grows past a handful of states, and many interviewers expect to see it drawn. The withdrawal steps stay the same either way: they live inside one state’s withdraw.

Offline withdrawals. When the network is down, some ATMs allow small withdrawals against a limit stored on the card’s chip, and settle with the bank later. That trades the bank’s live check for staying available, and the journal becomes a queue of captures to send later. Card terminals in shops make the same choice. It’s a business decision about acceptable risk.

Sagas in general. Hold, dispense, capture is a small saga: a series of steps across systems that can’t share one transaction, where each step has an “undo” (here, reverse). Large payment and booking systems are built the same way, with the same need for steps that are safe to repeat and a saved record of progress.

  • 0 to 3. Focus on withdrawals. Ask what the bank owns, and what happens on failure.
  • 3 to 5. The promises. Name “no failure loses money” as the hard one.
  • 5 to 25. The bank as an interface, then the session, then note planning, then hold, dispense, capture with the journal. Code withdraw and recover, and describe the planner.
  • 25 to 27. Walk through the full diagram: each system owns its truth, every step written first.
  • 27 to 34. Two ATMs and one account: the race at the bank and the one-step hold.
  • 34 to 45. Deposits or cash nobody takes, then your own questions.

If time is short, describe the session states in one sentence and spend the time on stage 4. The withdrawal is what the question is really about.

Interviewerminute 34

Add deposits. Customers put in a stack of notes, and the machine counts them.

What they are testing

Whether you reuse the same shape in reverse (count, confirm, credit), and see the new failure: the machine counts wrong, or the customer disagrees with the count.

Most extensions fit into one place:

Extension What changes Touches the withdrawal?
Deposits Count notes into a holding area, customer confirms, bank credits; same journal steps in reverse No
Balance check One bank call in the authenticated state No
Customer doesn’t take the cash The slot pulls the notes back after a timeout; the ATM reverses like a jam One branch
Note mix preferences The planner becomes a strategy: largest first, mixed, or keep a cassette from emptying No
Daily limits Checked in the bank’s authorize, under the account lock No
Receipts A listener on finished transactions No
Withdrawal with a phone code A second way into the authenticated state; the rest is unchanged No
Go deeperAdding deposits, step by step2 min

A deposit is the withdrawal run backwards. Cash comes into the machine first, and money moves at the bank last. The same two worries apply: what if something fails in between, and what if the power goes?

  1. Count into a holding area. The customer puts in a stack of notes. The machine counts them into an escrow area, separate from the cassettes. Notes it can’t read (torn, fake) are handed straight back. The journal records COUNTED with the amount.
  2. The customer confirms. The screen shows “You deposited 340. Confirm?” If they cancel, the notes in escrow go back to them, and nothing happened at the bank.
  3. The bank credits. The ATM calls credit with a transaction ID, so a retry can’t credit twice. The journal records CREDITED.
  4. Notes drop into the cassette. Only now does the cash join the machine’s own stock. The journal records STORED.

On restart, recovery follows the same idea as before. COUNTED without a confirmation: return the notes if they’re still in escrow, or flag it. CREDITED but not STORED: move the notes from escrow to the cassette. And the new failure, a customer who says “I put in 400, not 340”, goes to the same person who settles a power cut mid-dispense. They compare the escrow and reject counts with the journal.

Notice what didn’t change: the session states, the bank interface’s shape, and the journal. Deposits add steps, not new ideas.

Go deeperFour more follow-up questions2 min
Interviewer asksHow does the bank know a capture belongs to the right hold?

By the transaction ID, which the ATM creates and sends with every call. In real networks, the ID combines the machine’s ID, a sequence number and a time, so it’s unique across every ATM. The bank stores holds by that ID, which is also what makes repeated calls harmless.

Interviewer asksThe cassettes run low during the day. How does the machine know?

CashDispenser already counts notes, so it can send an event when a cassette drops below a set level, and the planner naturally stops using an empty cassette. A monitoring system collects those events from every ATM to plan refill routes.

Interviewer asksHow would you stop a skimmer from stealing card numbers?

Mostly hardware and protocol: chip cards create a one-time code for each transaction, so a copied number alone can’t be reused, and the PIN is encrypted inside the PIN pad. In the design, the ATM never keeps a card number after endSession, and never writes it to the journal. The journal stores transaction IDs only.

Interviewer asksWhy not let the ATM charge the account from a local database?

Because then every ATM holds a copy of every balance it might touch, and keeping those copies in agreement is a far harder problem than calling the bank. The bank is the single owner of each account’s balance, and every ATM asks it.