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.
- Read first
- Object modelingState
- Related
- Vending machineSplitwise
“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?
The answer in one minute
Section titled “The answer in one minute”If you only have a minute, this is the design. The rest of the page explains how we get here.
- 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.
- 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.
- 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.
- 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.
- 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.
Scope it
Section titled “Scope it”Before drawing anything, we ask a few questions. Each answer changes a class. Anything that doesn’t, we assume and move on.
Design an ATM.
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 |
Build it
Section titled “Build it”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).
Stage 1: A card, a PIN and a bank call
Section titled “Stage 1: A card, a PIN and a bank call”Start with the happy path. A customer takes out 100. What are your classes?
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.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// 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; }}from dataclasses import dataclassfrom typing import Protocol
# 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.@dataclass(frozen=True)class Card: number: str
# 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.class Bank(Protocol): def verify_pin(self, card: Card, pin: str) -> bool: ... def balance(self, card: Card) -> int: ... def withdraw(self, card: Card, amount: int) -> bool: ... # debits the account, or refuses
# The cash hardware: a motor that pushes notes out of the slot.class CashSlot(Protocol): def dispense(self, amount: int) -> bool: ... # False if the notes jammed
class Atm: def __init__(self, bank: Bank, slot: CashSlot): self._bank = bank self._slot = slot self._card: Card | None = None # the card in the reader
def insert_card(self, c: Card) -> None: self._card = c
def enter_pin(self, pin: str) -> bool: return self._bank.verify_pin(self._card, pin)
# flaw 1: nothing remembers whether the PIN was right. withdraw() works straight after # insert_card(), or with no card at all. Stage 2 adds a session with states. def withdraw(self, amount: int) -> bool: # 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 not self._bank.withdraw(self._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 self._slot.dispense(amount)
def eject_card(self) -> None: self._card = None// 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.public sealed 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.public interface IBank{ bool VerifyPin(Card card, string pin); long Balance(Card card); bool Withdraw(Card card, long amount); // debits the account, or refuses}
// The cash hardware: a motor that pushes notes out of the slot.public interface ICashSlot{ bool Dispense(long amount); // false if the notes jammed}
public sealed class Atm(IBank bank, ICashSlot slot){ private Card? _card; // the card in the reader
public void InsertCard(Card c) => _card = c;
public bool EnterPin(string pin) => 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. public bool 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); }
public void EjectCard() => _card = null;}// Card is what the ATM reads from the 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.type Card struct { Number string}
// Bank is 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.type Bank interface { VerifyPin(card Card, pin string) bool Balance(card Card) int64 Withdraw(card Card, amount int64) bool // debits the account, or refuses}
// CashSlot is the cash hardware: a motor that pushes notes out of the slot.type CashSlot interface { Dispense(amount int64) bool // false if the notes jammed}
type ATM struct { bank Bank slot CashSlot card *Card // the card in the reader, or nil}
func NewATM(bank Bank, slot CashSlot) *ATM { return &ATM{bank: bank, slot: slot} }
func (a *ATM) InsertCard(c Card) { a.card = &c }
func (a *ATM) EnterPin(pin string) bool { return a.bank.VerifyPin(*a.card, pin) }
// Withdraw has three flaws. flaw 1: nothing remembers whether the PIN was right, so it works// straight after InsertCard. Stage 2 adds a session with states.func (a *ATM) Withdraw(amount int64) bool { // 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 !a.bank.Withdraw(*a.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 a.slot.Dispense(amount)}
func (a *ATM) EjectCard() { a.card = nil }// 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.interface Card { readonly number: string;}
// 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 { verifyPin(card: Card, pin: string): boolean; balance(card: Card): number; withdraw(card: Card, amount: number): boolean; // debits the account, or refuses}
// The cash hardware: a motor that pushes notes out of the slot.interface CashSlot { dispense(amount: number): boolean; // false if the notes jammed}
class Atm { private card: Card | null = null; // the card in the reader
constructor( private readonly bank: Bank, private readonly slot: CashSlot, ) {}
insertCard(c: Card): void { this.card = c; }
enterPin(pin: string): boolean { return this.bank.verifyPin(this.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. withdraw(amount: number): boolean { // 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 (!this.bank.withdraw(this.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 this.slot.dispense(amount); }
ejectCard(): void { this.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.
Stage 2: A session with states
Section titled “Stage 2: A session with states”What stops someone withdrawing before entering a PIN? And what about a stolen card?
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.
- Color key
- State
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// 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; }}from enum import Enum
# Where the session stands. Every operation is legal in exactly one of these.class AtmState(Enum): IDLE = "idle" CARD_INSERTED = "card inserted" AUTHENTICATED = "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.class Result(Enum): OK = "ok" WRONG_STATE = "wrong state" WRONG_PIN = "wrong pin" CARD_RETAINED = "card retained" DECLINED = "declined" DISPENSE_FAILED = "dispense failed"
class Atm: MAX_PIN_TRIES = 3
def __init__(self, bank: Bank, slot: CashSlot): self._bank = bank self._slot = slot self.state = AtmState.IDLE self._card: Card | None = None self._pin_tries = 0
def insert_card(self, c: Card) -> Result: if self.state is not AtmState.IDLE: return Result.WRONG_STATE self._card, self._pin_tries, self.state = c, 0, AtmState.CARD_INSERTED return Result.OK
def enter_pin(self, pin: str) -> Result: if self.state is not AtmState.CARD_INSERTED: return Result.WRONG_STATE # The PIN goes to the bank and is forgotten. The ATM never stores or compares it. if self._bank.verify_pin(self._card, pin): self.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. self._pin_tries += 1 if self._pin_tries >= self.MAX_PIN_TRIES: self._end_session() return Result.CARD_RETAINED return Result.WRONG_PIN
def withdraw(self, amount: int) -> Result: if self.state is not AtmState.AUTHENTICATED: return Result.WRONG_STATE if not self._bank.withdraw(self._card, amount): return Result.DECLINED return Result.OK if self._slot.dispense(amount) else Result.DISPENSE_FAILED # still flaw 2
def eject_card(self) -> Result: if self.state is AtmState.IDLE: return Result.WRONG_STATE self._end_session() return Result.OK
# One place that clears the session, so no path can leave a card number behind. def _end_session(self) -> None: self._card, self._pin_tries, self.state = None, 0, AtmState.IDLE// Where the session stands. Every operation is legal in exactly one of these.public enum AtmState { Idle, CardInserted, 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.public enum Result { Ok, WrongState, WrongPin, CardRetained, Declined, DispenseFailed }
public sealed class Atm(IBank bank, ICashSlot slot){ public const int MaxPinTries = 3;
private Card? _card; private int _pinTries;
public AtmState State { get; private set; } = AtmState.Idle;
public Result InsertCard(Card c) { if (State != AtmState.Idle) return Result.WrongState; _card = c; _pinTries = 0; State = AtmState.CardInserted; return Result.Ok; }
public Result EnterPin(string pin) { if (State != AtmState.CardInserted) return Result.WrongState; // 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 >= MaxPinTries) { EndSession(); return Result.CardRetained; } return Result.WrongPin; }
public Result Withdraw(long amount) { if (State != AtmState.Authenticated) return Result.WrongState; if (!bank.Withdraw(_card!, amount)) return Result.Declined; return slot.Dispense(amount) ? Result.Ok : Result.DispenseFailed; // still flaw 2 }
public Result EjectCard() { if (State == AtmState.Idle) return Result.WrongState; 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; }}// State is where the session stands. Every operation is legal in exactly one of these.type State int
const ( Idle State = iota CardInserted Authenticated)
// Every way an operation can be refused. A wrong PIN or an operation in the wrong state is// normal customer behavior, so each is a named error the screen shows.var ( ErrWrongState = errors.New("not allowed now") ErrWrongPin = errors.New("wrong PIN") ErrCardRetained = errors.New("card retained") ErrDeclined = errors.New("declined by the bank") ErrDispenseFailed = errors.New("could not dispense"))
const MaxPinTries = 3
type ATM struct { bank Bank slot CashSlot State State card Card pinTries int}
func NewATM(bank Bank, slot CashSlot) *ATM { return &ATM{bank: bank, slot: slot} }
func (a *ATM) InsertCard(c Card) error { if a.State != Idle { return ErrWrongState } a.card, a.pinTries, a.State = c, 0, CardInserted return nil}
func (a *ATM) EnterPin(pin string) error { if a.State != CardInserted { return ErrWrongState } // The PIN goes to the bank and is forgotten. The ATM never stores or compares it. if a.bank.VerifyPin(a.card, pin) { a.State = Authenticated return nil } // 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 a.pinTries++; a.pinTries >= MaxPinTries { a.endSession() return ErrCardRetained } return ErrWrongPin}
func (a *ATM) Withdraw(amount int64) error { if a.State != Authenticated { return ErrWrongState } if !a.bank.Withdraw(a.card, amount) { return ErrDeclined } if !a.slot.Dispense(amount) { // still flaw 2 return ErrDispenseFailed } return nil}
func (a *ATM) EjectCard() error { if a.State == Idle { return ErrWrongState } a.endSession() return nil}
// endSession is the one place that clears the session, so no path can leave a card behind.func (a *ATM) endSession() { a.card, a.pinTries, a.State = Card{}, 0, Idle }// Where the session stands. Every operation is legal in exactly one of these.type 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.type Result = 'ok' | 'wrong-state' | 'wrong-pin' | 'card-retained' | 'declined' | 'dispense-failed';
const MAX_PIN_TRIES = 3;
class Atm { state: AtmState = 'idle'; private card: Card | null = null; private pinTries = 0;
constructor( private readonly bank: Bank, private readonly slot: CashSlot, ) {}
insertCard(c: Card): Result { if (this.state !== 'idle') return 'wrong-state'; this.card = c; this.pinTries = 0; this.state = 'card-inserted'; return 'ok'; }
enterPin(pin: string): Result { if (this.state !== 'card-inserted' || !this.card) return 'wrong-state'; // The PIN goes to the bank and is forgotten. The ATM never stores or compares it. if (this.bank.verifyPin(this.card, pin)) { this.state = 'authenticated'; return '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 (++this.pinTries >= MAX_PIN_TRIES) { this.endSession(); return 'card-retained'; } return 'wrong-pin'; }
withdraw(amount: number): Result { if (this.state !== 'authenticated' || !this.card) return 'wrong-state'; if (!this.bank.withdraw(this.card, amount)) return 'declined'; return this.slot.dispense(amount) ? 'ok' : 'dispense-failed'; // still flaw 2 }
ejectCard(): Result { if (this.state === 'idle') return 'wrong-state'; this.endSession(); return 'ok'; }
// One place that clears the session, so no path can leave a card number behind. private endSession(): void { this.card = null; this.pinTries = 0; this.state = 'idle'; }}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.
Stage 3: Planning the notes
Section titled “Stage 3: Planning the notes”The machine has 20s and 50s, and some cassettes are running low. A customer asks for 130. What comes out?
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.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// 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; }}from dataclasses import dataclass, fieldfrom enum import Enumfrom typing import Protocol
# The notes for one withdrawal: denomination -> how many. A value, so a plan can be made,# checked and logged before anything moves.@dataclass(frozen=True)class NotePlan: notes: dict[int, int] = field(hash=False)
def total(self) -> int: return sum(d * n for d, n in self.notes.items())
# The cash hardware. It is told exactly which notes to hand out, not just an amount.class CashSlot(Protocol): def dispense(self, plan: NotePlan) -> bool: ... # False if the notes jammed
# Knows what is in the cassettes and how to make an amount from it.class CashDispenser: def __init__(self, loaded: dict[int, int], slot: CashSlot): self._cassettes = dict(loaded) # denomination -> notes left self._slot = slot
# Notes that make the amount exactly from what is loaded, preferring large notes, or None. # 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. def plan(self, amount: int) -> NotePlan | None: denoms = sorted(self._cassettes, reverse=True) dead: set[tuple[int, int]] = set()
def search(i: int, left: int) -> dict[int, int] | None: if left == 0: return {} if i == len(denoms) or (i, left) in dead: return None d = denoms[i] for n in range(min(self._cassettes[d], left // d), -1, -1): rest = search(i + 1, left - n * d) if rest is not None: return {d: n, **rest} if n else rest dead.add((i, left)) # from this cassette onward, this remainder can't be made return None
notes = search(0, amount) return NotePlan(notes) if notes is not None else None
# 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. def dispense(self, p: NotePlan) -> bool: if not self._slot.dispense(p): return False for d, n in p.notes.items(): self._cassettes[d] -= n return True
# What each ATM action returns. CANNOT_DISPENSE means the machine can't make this amount from# its notes.class Result(Enum): OK = "ok" WRONG_STATE = "wrong state" WRONG_PIN = "wrong pin" CARD_RETAINED = "card retained" CANNOT_DISPENSE = "cannot dispense" DECLINED = "declined" DISPENSE_FAILED = "dispense failed"
class Atm: MAX_PIN_TRIES = 3
def __init__(self, bank: Bank, dispenser: CashDispenser): self._bank = bank self._dispenser = dispenser self.state = AtmState.IDLE self._card: Card | None = None self._pin_tries = 0
def insert_card(self, c: Card) -> Result: if self.state is not AtmState.IDLE: return Result.WRONG_STATE self._card, self._pin_tries, self.state = c, 0, AtmState.CARD_INSERTED return Result.OK
def enter_pin(self, pin: str) -> Result: if self.state is not AtmState.CARD_INSERTED: return Result.WRONG_STATE if self._bank.verify_pin(self._card, pin): self.state = AtmState.AUTHENTICATED return Result.OK self._pin_tries += 1 if self._pin_tries >= self.MAX_PIN_TRIES: self._end_session() return Result.CARD_RETAINED return Result.WRONG_PIN
def withdraw(self, amount: int) -> Result: if self.state is not AtmState.AUTHENTICATED: return Result.WRONG_STATE # Plan the notes first. Never take money for cash the machine can't hand over. plan = self._dispenser.plan(amount) if plan is None: return Result.CANNOT_DISPENSE if not self._bank.withdraw(self._card, amount): return Result.DECLINED # Still flaw 2: the account is already debited if this jams. Stage 4. return Result.OK if self._dispenser.dispense(plan) else Result.DISPENSE_FAILED
def eject_card(self) -> Result: if self.state is AtmState.IDLE: return Result.WRONG_STATE self._end_session() return Result.OK
def _end_session(self) -> None: self._card, self._pin_tries, self.state = None, 0, AtmState.IDLE// The notes for one withdrawal: denomination -> how many. A value, so a plan can be made,// checked and logged before anything moves.public sealed record NotePlan(IReadOnlyDictionary<int, int> Notes){ public long Total => Notes.Sum(e => (long)e.Key * e.Value);}
// The cash hardware. It is told exactly which notes to hand out, not just an amount.public interface ICashSlot{ bool Dispense(NotePlan plan); // false if the notes jammed}
// Knows what is in the cassettes and how to make an amount from it.public sealed class CashDispenser(IReadOnlyDictionary<int, int> loaded, ICashSlot slot){ private readonly Dictionary<int, int> _cassettes = new(loaded); // denomination -> notes left
// Notes that make the amount exactly from what is loaded, preferring large notes, or null. // 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. public NotePlan? Plan(long amount) { var denoms = _cassettes.Keys.OrderByDescending(d => d).ToList(); var dead = new HashSet<(int, long)>(); var chosen = new Dictionary<int, int>();
bool Search(int i, long left) { if (left == 0) return true; if (i == denoms.Count || dead.Contains((i, left))) return false; int d = denoms[i]; for (long n = Math.Min(_cassettes[d], left / d); n >= 0; n--) { if (n > 0) chosen[d] = (int)n; else chosen.Remove(d); if (Search(i + 1, left - n * d)) return true; } chosen.Remove(d); dead.Add((i, left)); // from this cassette onward, this remainder can't be made return false; }
return Search(0, amount) ? new NotePlan(chosen) : null; }
// 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. public bool Dispense(NotePlan p) { if (!slot.Dispense(p)) return false; foreach (var (d, n) in p.Notes) _cassettes[d] -= n; return true; }}
// What each ATM action returns. CannotDispense means the machine can't make this amount from// its notes.public enum Result { Ok, WrongState, WrongPin, CardRetained, CannotDispense, Declined, DispenseFailed }
public sealed class Atm(IBank bank, CashDispenser dispenser){ public const int MaxPinTries = 3;
private Card? _card; private int _pinTries;
public AtmState State { get; private set; } = AtmState.Idle;
public Result InsertCard(Card c) { if (State != AtmState.Idle) return Result.WrongState; _card = c; _pinTries = 0; State = AtmState.CardInserted; return Result.Ok; }
public Result EnterPin(string pin) { if (State != AtmState.CardInserted) return Result.WrongState; if (bank.VerifyPin(_card!, pin)) { State = AtmState.Authenticated; return Result.Ok; } if (++_pinTries >= MaxPinTries) { EndSession(); return Result.CardRetained; } return Result.WrongPin; }
public Result Withdraw(long amount) { if (State != AtmState.Authenticated) return Result.WrongState; // Plan the notes first. Never take money for cash the machine can't hand over. if (dispenser.Plan(amount) is not { } plan) return Result.CannotDispense; 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) ? Result.Ok : Result.DispenseFailed; }
public Result EjectCard() { if (State == AtmState.Idle) return Result.WrongState; EndSession(); return Result.Ok; }
private void EndSession() { _card = null; _pinTries = 0; State = AtmState.Idle; }}// NotePlan is the notes for one withdrawal: denomination -> how many. A value, so a plan can// be made, checked and logged before anything moves.type NotePlan map[int]int
func (p NotePlan) Total() int64 { var t int64 for d, n := range p { t += int64(d * n) } return t}
// CashSlot is the cash hardware. It is told exactly which notes to hand out, not just an// amount.type CashSlot interface { Dispense(p NotePlan) bool // false if the notes jammed}
// CashDispenser knows what is in the cassettes and how to make an amount from it.type CashDispenser struct { cassettes map[int]int // denomination -> notes left slot CashSlot}
func NewCashDispenser(loaded map[int]int, slot CashSlot) *CashDispenser { return &CashDispenser{cassettes: maps.Clone(loaded), slot: slot}}
// Plan returns notes that make the amount exactly from what is loaded, preferring large// notes. 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.func (d *CashDispenser) Plan(amount int64) (NotePlan, bool) { denoms := slices.Sorted(maps.Keys(d.cassettes)) slices.Reverse(denoms) type state struct { i int left int64 } dead := map[state]bool{} chosen := NotePlan{} var search func(i int, left int64) bool search = func(i int, left int64) bool { if left == 0 { return true } if i == len(denoms) || dead[state{i, left}] { return false } den := denoms[i] for n := min(int64(d.cassettes[den]), left/int64(den)); n >= 0; n-- { if n > 0 { chosen[den] = int(n) } else { delete(chosen, den) } if search(i+1, left-n*int64(den)) { return true } } delete(chosen, den) dead[state{i, left}] = true // from this cassette onward, this remainder can't be made return false } if !search(0, amount) { return nil, false } return chosen, true}
// Dispense lowers the counts only after the notes are out. A jam leaves them unknown, which// the operator settles at the next refill by counting the reject bin.func (d *CashDispenser) Dispense(p NotePlan) bool { if !d.slot.Dispense(p) { return false } for den, n := range p { d.cassettes[den] -= n } return true}
// ErrCannotDispense means the machine can't make this amount from its notes.var ErrCannotDispense = errors.New("cannot dispense that amount")
type ATM struct { bank Bank dispenser *CashDispenser State State card Card pinTries int}
func NewATM(bank Bank, d *CashDispenser) *ATM { return &ATM{bank: bank, dispenser: d} }
func (a *ATM) InsertCard(c Card) error { if a.State != Idle { return ErrWrongState } a.card, a.pinTries, a.State = c, 0, CardInserted return nil}
func (a *ATM) EnterPin(pin string) error { if a.State != CardInserted { return ErrWrongState } if a.bank.VerifyPin(a.card, pin) { a.State = Authenticated return nil } if a.pinTries++; a.pinTries >= MaxPinTries { a.endSession() return ErrCardRetained } return ErrWrongPin}
func (a *ATM) Withdraw(amount int64) error { if a.State != Authenticated { return ErrWrongState } // Plan the notes first. Never take money for cash the machine can't hand over. plan, ok := a.dispenser.Plan(amount) if !ok { return ErrCannotDispense } if !a.bank.Withdraw(a.card, amount) { return ErrDeclined } if !a.dispenser.Dispense(plan) { // still flaw 2: the account is already debited. Stage 4. return ErrDispenseFailed } return nil}
func (a *ATM) EjectCard() error { if a.State == Idle { return ErrWrongState } a.endSession() return nil}
func (a *ATM) endSession() { a.card, a.pinTries, a.State = Card{}, 0, Idle }// The notes for one withdrawal: denomination -> how many. A value, so a plan can be made,// checked and logged before anything moves.type NotePlan = ReadonlyMap<number, number>;
const total = (p: NotePlan) => [...p].reduce((t, [d, n]) => t + d * n, 0);
// The cash hardware. It is told exactly which notes to hand out, not just an amount.interface CashSlot { dispense(plan: NotePlan): boolean; // false if the notes jammed}
// Knows what is in the cassettes and how to make an amount from it.class CashDispenser { private readonly cassettes: Map<number, number>; // denomination -> notes left
constructor( loaded: ReadonlyMap<number, number>, private readonly slot: CashSlot, ) { this.cassettes = new Map(loaded); }
// Notes that make the amount exactly from what is loaded, preferring large notes, or null. // 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. plan(amount: number): NotePlan | null { const denoms = [...this.cassettes.keys()].sort((a, b) => b - a); const dead = new Set<string>(); const chosen = new Map<number, number>(); const search = (i: number, left: number): boolean => { if (left === 0) return true; if (i === denoms.length || dead.has(`${i}:${left}`)) return false; const d = denoms[i]; for (let n = Math.min(this.cassettes.get(d)!, Math.floor(left / d)); n >= 0; n--) { if (n > 0) chosen.set(d, n); else chosen.delete(d); if (search(i + 1, left - n * d)) return true; } chosen.delete(d); dead.add(`${i}:${left}`); // from this cassette onward, this remainder can't be made return false; }; return search(0, amount) ? chosen : null; }
// 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. dispense(p: NotePlan): boolean { if (!this.slot.dispense(p)) return false; for (const [d, n] of p) this.cassettes.set(d, this.cassettes.get(d)! - n); return true; }}
// What each ATM action returns. 'cannot-dispense' means the machine can't make this amount from// its notes.type Result = 'ok' | 'wrong-state' | 'wrong-pin' | 'card-retained' | 'cannot-dispense' | 'declined' | 'dispense-failed';
class Atm { state: AtmState = 'idle'; private card: Card | null = null; private pinTries = 0;
constructor( private readonly bank: Bank, private readonly dispenser: CashDispenser, ) {}
insertCard(c: Card): Result { if (this.state !== 'idle') return 'wrong-state'; this.card = c; this.pinTries = 0; this.state = 'card-inserted'; return 'ok'; }
enterPin(pin: string): Result { if (this.state !== 'card-inserted' || !this.card) return 'wrong-state'; if (this.bank.verifyPin(this.card, pin)) { this.state = 'authenticated'; return 'ok'; } if (++this.pinTries >= MAX_PIN_TRIES) { this.endSession(); return 'card-retained'; } return 'wrong-pin'; }
withdraw(amount: number): Result { if (this.state !== 'authenticated' || !this.card) return 'wrong-state'; // Plan the notes first. Never take money for cash the machine can't hand over. const plan = this.dispenser.plan(amount); if (!plan) return 'cannot-dispense'; if (!this.bank.withdraw(this.card, amount)) return 'declined'; // Still flaw 2: the account is already debited if this jams. Stage 4. return this.dispenser.dispense(plan) ? 'ok' : 'dispense-failed'; }
ejectCard(): Result { if (this.state === 'idle') return 'wrong-state'; this.endSession(); return 'ok'; }
private endSession(): void { this.card = null; this.pinTries = 0; this.state = 'idle'; }}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”The notes jam after the bank approved. Or the power goes out halfway through. What happens to the customer’s money?
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:
- Authorize a hold for the amount. The money is set aside, but not taken.
- Dispense the notes.
- 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.
- Color key
- Entity
- Interface
- Facade
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// 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; }}from enum import Enumfrom typing import Callable, Protocol
# 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.class Bank(Protocol): def verify_pin(self, card: Card, pin: str) -> bool: ... def authorize(self, txn_id: str, card: Card, amount: int) -> bool: ... # place a hold, or refuse def capture(self, txn_id: str) -> None: ... # the cash is out: make it final def reverse(self, txn_id: str) -> None: ... # no cash: release the hold
# How far a withdrawal got. The journal's last step for a transaction says what recovery owes.class Step(Enum): STARTED = "started" DECLINED = "declined" DISPENSING = "dispensing" DISPENSED = "dispensed" CAPTURED = "captured" REVERSED = "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.class Journal(Protocol): def record(self, txn_id: str, step: Step) -> None: ... def last_steps(self) -> dict[str, Step]: ... # 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: def __init__(self): self._last: dict[str, Step] = {} # dicts keep insertion order
def record(self, txn_id: str, step: Step) -> None: self._last[txn_id] = step
def last_steps(self) -> dict[str, Step]: return dict(self._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: MAX_PIN_TRIES = 3
def __init__(self, bank: Bank, dispenser: CashDispenser, journal: Journal, ids: Callable[[], str]): self._bank = bank self._dispenser = dispenser self._journal = journal self._ids = ids # unique transaction ids; injected so tests can name them self.state = AtmState.IDLE self._card: Card | None = None self._pin_tries = 0
def insert_card(self, c: Card) -> Result: if self.state is not AtmState.IDLE: return Result.WRONG_STATE self._card, self._pin_tries, self.state = c, 0, 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. def enter_pin(self, pin: str) -> Result: if self.state is not AtmState.CARD_INSERTED: return Result.WRONG_STATE if self._bank.verify_pin(self._card, pin): self.state = AtmState.AUTHENTICATED return Result.OK self._pin_tries += 1 if self._pin_tries >= self.MAX_PIN_TRIES: self._end_session() 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. def withdraw(self, amount: int) -> Result: if self.state is not AtmState.AUTHENTICATED: return Result.WRONG_STATE plan = self._dispenser.plan(amount) if plan is None: return Result.CANNOT_DISPENSE
txn = self._ids() self._journal.record(txn, Step.STARTED) # before the bank hears of it if not self._bank.authorize(txn, self._card, amount): self._journal.record(txn, Step.DECLINED) return Result.DECLINED self._journal.record(txn, Step.DISPENSING) if not self._dispenser.dispense(plan): self._bank.reverse(txn) # the notes never left: release the hold self._journal.record(txn, Step.REVERSED) return Result.DISPENSE_FAILED self._journal.record(txn, Step.DISPENSED) self._bank.capture(txn) self._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. def recover(self) -> list[str]: suspect = [] for txn, step in self._journal.last_steps().items(): if step is Step.STARTED: # A hold may or may not exist. Reversing an id the bank never saw does nothing, # so reversing is always safe here. self._bank.reverse(txn) self._journal.record(txn, Step.REVERSED) elif step is Step.DISPENSED: # The notes are out; the customer has the cash. Make the debit final. self._bank.capture(txn) self._journal.record(txn, Step.CAPTURED) elif step is Step.DISPENSING: # 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. suspect.append(txn) return suspect
def eject_card(self) -> Result: if self.state is AtmState.IDLE: return Result.WRONG_STATE self._end_session() return Result.OK
# Forget the card and the PIN count, ready for the next customer. def _end_session(self) -> None: self._card, self._pin_tries, self.state = None, 0, AtmState.IDLE// 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.public interface IBank{ bool VerifyPin(Card card, string pin); bool 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.public 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.public interface IJournal{ void Record(string txnId, Step step); IReadOnlyList<(string TxnId, Step 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.public sealed class InMemoryJournal : IJournal{ private readonly List<string> _order = []; private readonly Dictionary<string, Step> _last = new();
public void Record(string txnId, Step step) { if (!_last.ContainsKey(txnId)) _order.Add(txnId); _last[txnId] = step; }
public IReadOnlyList<(string TxnId, Step Step)> LastSteps() => _order.Select(id => (id, _last[id])).ToList();}
// ids hands out unique transaction ids; injected so tests can name them.public sealed class Atm(IBank bank, CashDispenser dispenser, IJournal journal, Func<string> ids){ public const int MaxPinTries = 3;
private Card? _card; private int _pinTries;
public AtmState State { get; private set; } = AtmState.Idle;
public Result InsertCard(Card c) { if (State != AtmState.Idle) return Result.WrongState; _card = c; _pinTries = 0; State = AtmState.CardInserted; return Result.Ok; }
// Three wrong PINs in a row and the machine keeps the card, so a thief can't keep guessing. public Result EnterPin(string pin) { if (State != AtmState.CardInserted) return Result.WrongState; if (bank.VerifyPin(_card!, pin)) { State = AtmState.Authenticated; return Result.Ok; } if (++_pinTries >= MaxPinTries) { EndSession(); return Result.CardRetained; } return Result.WrongPin; }
// 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. public Result Withdraw(long amount) { if (State != AtmState.Authenticated) return Result.WrongState; if (dispenser.Plan(amount) is not { } plan) return Result.CannotDispense;
string txn = ids(); 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)) { bank.Reverse(txn); // the notes never left: release the hold journal.Record(txn, Step.Reversed); return Result.DispenseFailed; } 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. public List<string> Recover() { var suspect = new List<string>(); foreach (var (txn, step) in journal.LastSteps()) { switch (step) { // A hold may or may not exist. Reversing an id the bank never saw does nothing, // so reversing is always safe here. case Step.Started: bank.Reverse(txn); journal.Record(txn, Step.Reversed); break; // The notes are out; the customer has the cash. Make the debit final. case Step.Dispensed: bank.Capture(txn); journal.Record(txn, Step.Captured); break; // 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 Step.Dispensing: suspect.Add(txn); break; } } return suspect; }
public Result EjectCard() { if (State == AtmState.Idle) return Result.WrongState; 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; }}// Bank is 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.type Bank interface { VerifyPin(card Card, pin string) bool Authorize(txnID string, card Card, amount int64) bool // place a hold, or refuse Capture(txnID string) // the cash is out: make it final Reverse(txnID string) // no cash: release the hold}
// Step is how far a withdrawal got. The journal's last step says what recovery owes.type Step int
const ( Started Step = iota Declined Dispensing Dispensed Captured Reversed)
// Journal is 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.type Journal interface { Record(txnID string, s Step) LastSteps() []TxnStep // each transaction's latest step, oldest first}
// TxnStep is one transaction's latest step, as recovery reads it.type TxnStep struct { TxnID string Step Step}
// MemoryJournal keeps the journal in memory, for tests. A real ATM writes it to storage that// survives a power cut. order keeps the oldest transaction first for recovery.type MemoryJournal struct { order []string last map[string]Step}
func NewMemoryJournal() *MemoryJournal { return &MemoryJournal{last: map[string]Step{}} }
func (j *MemoryJournal) Record(id string, s Step) { if _, seen := j.last[id]; !seen { j.order = append(j.order, id) } j.last[id] = s}
func (j *MemoryJournal) LastSteps() []TxnStep { out := make([]TxnStep, 0, len(j.order)) for _, id := range j.order { out = append(out, TxnStep{id, j.last[id]}) } return out}
// ATM is 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.type ATM struct { bank Bank dispenser *CashDispenser journal Journal ids func() string // unique transaction ids; injected so tests can name them State State card Card pinTries int}
func NewATM(bank Bank, d *CashDispenser, j Journal, ids func() string) *ATM { return &ATM{bank: bank, dispenser: d, journal: j, ids: ids}}
func (a *ATM) InsertCard(c Card) error { if a.State != Idle { return ErrWrongState } a.card, a.pinTries, a.State = c, 0, CardInserted return nil}
// EnterPin: three wrong PINs in a row and the machine keeps the card, so a thief can't keep// guessing.func (a *ATM) EnterPin(pin string) error { if a.State != CardInserted { return ErrWrongState } if a.bank.VerifyPin(a.card, pin) { a.State = Authenticated return nil } if a.pinTries++; a.pinTries >= MaxPinTries { a.endSession() return ErrCardRetained } return ErrWrongPin}
// Withdraw holds, dispenses, then captures or reverses. Each step is journaled before it// happens, so a power cut at any line leaves enough on record for Recover to finish the job.func (a *ATM) Withdraw(amount int64) error { if a.State != Authenticated { return ErrWrongState } plan, ok := a.dispenser.Plan(amount) if !ok { return ErrCannotDispense }
txn := a.ids() a.journal.Record(txn, Started) // before the bank hears of it if !a.bank.Authorize(txn, a.card, amount) { a.journal.Record(txn, Declined) return ErrDeclined } a.journal.Record(txn, Dispensing) if !a.dispenser.Dispense(plan) { a.bank.Reverse(txn) // the notes never left: release the hold a.journal.Record(txn, Reversed) return ErrDispenseFailed } a.journal.Record(txn, Dispensed) a.bank.Capture(txn) a.journal.Record(txn, Captured) return nil}
// Recover runs at power-up, before the first customer. It finishes or undoes whatever was in// flight, and returns the transactions only a person counting the cash can settle.func (a *ATM) Recover() []string { var suspect []string for _, e := range a.journal.LastSteps() { switch e.Step { case Started: // A hold may or may not exist. Reversing an id the bank never saw does nothing, // so reversing is always safe here. a.bank.Reverse(e.TxnID) a.journal.Record(e.TxnID, Reversed) case Dispensed: // The notes are out; the customer has the cash. Make the debit final. a.bank.Capture(e.TxnID) a.journal.Record(e.TxnID, Captured) case Dispensing: // 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. suspect = append(suspect, e.TxnID) } } return suspect}
func (a *ATM) EjectCard() error { if a.State == Idle { return ErrWrongState } a.endSession() return nil}
// endSession forgets the card and the PIN count, ready for the next customer.func (a *ATM) endSession() { a.card, a.pinTries, a.State = Card{}, 0, Idle }// 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 { verifyPin(card: Card, pin: string): boolean; authorize(txnId: string, card: Card, amount: number): boolean; // place a hold, or refuse capture(txnId: string): void; // the cash is out: make it final reverse(txnId: string): void; // no cash: release the hold}
// How far a withdrawal got. The journal's last step for a transaction says what recovery owes.type 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 { record(txnId: string, step: Step): void; lastSteps(): ReadonlyMap<string, Step>; // 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 readonly last = new Map<string, Step>(); // Maps keep insertion order
record(txnId: string, step: Step): void { this.last.set(txnId, step); }
lastSteps(): ReadonlyMap<string, Step> { return new Map(this.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 { state: AtmState = 'idle'; private card: Card | null = null; private pinTries = 0;
constructor( private readonly bank: Bank, private readonly dispenser: CashDispenser, private readonly journal: Journal, private readonly ids: () => string, // unique transaction ids; injected so tests can name them ) {}
insertCard(c: Card): Result { if (this.state !== 'idle') return 'wrong-state'; this.card = c; this.pinTries = 0; this.state = 'card-inserted'; return 'ok'; }
// Three wrong PINs in a row and the machine keeps the card, so a thief can't keep guessing. enterPin(pin: string): Result { if (this.state !== 'card-inserted' || !this.card) return 'wrong-state'; if (this.bank.verifyPin(this.card, pin)) { this.state = 'authenticated'; return 'ok'; } if (++this.pinTries >= MAX_PIN_TRIES) { this.endSession(); return 'card-retained'; } return '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. withdraw(amount: number): Result { if (this.state !== 'authenticated' || !this.card) return 'wrong-state'; const plan = this.dispenser.plan(amount); if (!plan) return 'cannot-dispense';
const txn = this.ids(); this.journal.record(txn, 'started'); // before the bank hears of it if (!this.bank.authorize(txn, this.card, amount)) { this.journal.record(txn, 'declined'); return 'declined'; } this.journal.record(txn, 'dispensing'); if (!this.dispenser.dispense(plan)) { this.bank.reverse(txn); // the notes never left: release the hold this.journal.record(txn, 'reversed'); return 'dispense-failed'; } this.journal.record(txn, 'dispensed'); this.bank.capture(txn); this.journal.record(txn, 'captured'); return '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. recover(): string[] { const suspect: string[] = []; for (const [txn, step] of this.journal.lastSteps()) { switch (step) { case 'started': // A hold may or may not exist. Reversing an id the bank never saw does nothing, // so reversing is always safe here. this.bank.reverse(txn); this.journal.record(txn, 'reversed'); break; case 'dispensed': // The notes are out; the customer has the cash. Make the debit final. this.bank.capture(txn); this.journal.record(txn, 'captured'); break; case 'dispensing': // 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. suspect.push(txn); break; } } return suspect; }
ejectCard(): Result { if (this.state === 'idle') return 'wrong-state'; this.endSession(); return 'ok'; }
// Forget the card and the PIN count, ready for the next customer. private endSession(): void { this.card = null; this.pinTries = 0; this.state = 'idle'; }}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.
The full design
Section titled “The full design”Here is everything together. Each class is here because a requirement needed it. The in-memory bank comes from the next section.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
// 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; } }}# The complete design, built from every stage on this page.
from __future__ import annotations
import threadingfrom dataclasses import dataclass, fieldfrom enum import Enumfrom typing import Callable, Protocol
# 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.@dataclass(frozen=True)class Card: number: str
# 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.class Bank(Protocol): def verify_pin(self, card: Card, pin: str) -> bool: ... def authorize(self, txn_id: str, card: Card, amount: int) -> bool: ... # place a hold, or refuse def capture(self, txn_id: str) -> None: ... # the cash is out: make it final def reverse(self, txn_id: str) -> None: ... # no cash: release the hold
# The cash hardware. It is told exactly which notes to hand out, not just an amount.class CashSlot(Protocol): def dispense(self, plan: NotePlan) -> bool: ... # 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: MAX_PIN_TRIES = 3
def __init__(self, bank: Bank, dispenser: CashDispenser, journal: Journal, ids: Callable[[], str]): self._bank = bank self._dispenser = dispenser self._journal = journal self._ids = ids # unique transaction ids; injected so tests can name them self.state = AtmState.IDLE self._card: Card | None = None self._pin_tries = 0
def insert_card(self, c: Card) -> Result: if self.state is not AtmState.IDLE: return Result.WRONG_STATE self._card, self._pin_tries, self.state = c, 0, 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. def enter_pin(self, pin: str) -> Result: if self.state is not AtmState.CARD_INSERTED: return Result.WRONG_STATE if self._bank.verify_pin(self._card, pin): self.state = AtmState.AUTHENTICATED return Result.OK self._pin_tries += 1 if self._pin_tries >= self.MAX_PIN_TRIES: self._end_session() 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. def withdraw(self, amount: int) -> Result: if self.state is not AtmState.AUTHENTICATED: return Result.WRONG_STATE plan = self._dispenser.plan(amount) if plan is None: return Result.CANNOT_DISPENSE
txn = self._ids() self._journal.record(txn, Step.STARTED) # before the bank hears of it if not self._bank.authorize(txn, self._card, amount): self._journal.record(txn, Step.DECLINED) return Result.DECLINED self._journal.record(txn, Step.DISPENSING) if not self._dispenser.dispense(plan): self._bank.reverse(txn) # the notes never left: release the hold self._journal.record(txn, Step.REVERSED) return Result.DISPENSE_FAILED self._journal.record(txn, Step.DISPENSED) self._bank.capture(txn) self._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. def recover(self) -> list[str]: suspect = [] for txn, step in self._journal.last_steps().items(): if step is Step.STARTED: # A hold may or may not exist. Reversing an id the bank never saw does nothing, # so reversing is always safe here. self._bank.reverse(txn) self._journal.record(txn, Step.REVERSED) elif step is Step.DISPENSED: # The notes are out; the customer has the cash. Make the debit final. self._bank.capture(txn) self._journal.record(txn, Step.CAPTURED) elif step is Step.DISPENSING: # 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. suspect.append(txn) return suspect
def eject_card(self) -> Result: if self.state is AtmState.IDLE: return Result.WRONG_STATE self._end_session() return Result.OK
# Forget the card and the PIN count, ready for the next customer. def _end_session(self) -> None: self._card, self._pin_tries, self.state = None, 0, AtmState.IDLE
# Where the session stands. Every operation is legal in exactly one of these.class AtmState(Enum): IDLE = "idle" CARD_INSERTED = "card inserted" AUTHENTICATED = "authenticated"
# What each ATM action returns. CANNOT_DISPENSE means the machine can't make this amount from# its notes.class Result(Enum): OK = "ok" WRONG_STATE = "wrong state" WRONG_PIN = "wrong pin" CARD_RETAINED = "card retained" CANNOT_DISPENSE = "cannot dispense" DECLINED = "declined" DISPENSE_FAILED = "dispense failed"
# The notes for one withdrawal: denomination -> how many. A value, so a plan can be made,# checked and logged before anything moves.@dataclass(frozen=True)class NotePlan: notes: dict[int, int] = field(hash=False)
def total(self) -> int: return sum(d * n for d, n in self.notes.items())
# Knows what is in the cassettes and how to make an amount from it.class CashDispenser: def __init__(self, loaded: dict[int, int], slot: CashSlot): self._cassettes = dict(loaded) # denomination -> notes left self._slot = slot
# Notes that make the amount exactly from what is loaded, preferring large notes, or None. # 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. def plan(self, amount: int) -> NotePlan | None: denoms = sorted(self._cassettes, reverse=True) dead: set[tuple[int, int]] = set()
def search(i: int, left: int) -> dict[int, int] | None: if left == 0: return {} if i == len(denoms) or (i, left) in dead: return None d = denoms[i] for n in range(min(self._cassettes[d], left // d), -1, -1): rest = search(i + 1, left - n * d) if rest is not None: return {d: n, **rest} if n else rest dead.add((i, left)) # from this cassette onward, this remainder can't be made return None
notes = search(0, amount) return NotePlan(notes) if notes is not None else None
# 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. def dispense(self, p: NotePlan) -> bool: if not self._slot.dispense(p): return False for d, n in p.notes.items(): self._cassettes[d] -= n return True
# How far a withdrawal got. The journal's last step for a transaction says what recovery owes.class Step(Enum): STARTED = "started" DECLINED = "declined" DISPENSING = "dispensing" DISPENSED = "dispensed" CAPTURED = "captured" REVERSED = "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.class Journal(Protocol): def record(self, txn_id: str, step: Step) -> None: ... def last_steps(self) -> dict[str, Step]: ... # 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: def __init__(self): self._last: dict[str, Step] = {} # dicts keep insertion order
def record(self, txn_id: str, step: Step) -> None: self._last[txn_id] = step
def last_steps(self) -> dict[str, Step]: return dict(self._last)
# One customer account. held is money promised to withdrawals that haven't finished, so what# can be spent is balance minus held.class _Account: def __init__(self, pin: str, balance: int): self.pin = pin self.balance = balance self.held = 0 # reserved by authorized withdrawals that are not captured yet self.lock = threading.Lock() # one per account: other accounts never wait on this one
# A hold: which account and how much, so capture and reverse know what to settle.@dataclass(frozen=True)class _Hold: account: _Account amount: int
# 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: def __init__(self): self._accounts: dict[str, _Account] = {} self._holds: dict[str, _Hold] = {} self._decisions: dict[str, bool] = {} # txn id -> first answer
# Sets up an account. Tests call it; a real bank already has its accounts. def open(self, card: Card, pin: str, balance: int) -> None: self._accounts[card.number] = _Account(pin, balance)
def verify_pin(self, card: Card, pin: str) -> bool: a = self._accounts.get(card.number) return a is not None and a.pin == pin # a real bank compares a hash inside an HSM
def authorize(self, txn_id: str, card: Card, amount: int) -> bool: a = self._accounts.get(card.number) if a is None: return False # "Is there enough?" and "hold it" are one step under the account's lock, so two ATMs # can't both see 100 available and both hold it. with a.lock: if txn_id in self._decisions: return self._decisions[txn_id] # a retry gets the first answer, not a second hold ok = a.balance - a.held >= amount if ok: a.held += amount self._holds[txn_id] = _Hold(a, amount) self._decisions[txn_id] = ok return ok
# capture and reverse both start by removing the hold. dict.pop is atomic in CPython, 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. def capture(self, txn_id: str) -> None: h = self._holds.pop(txn_id, None) if h is None: return with h.account.lock: h.account.held -= h.amount h.account.balance -= h.amount
def reverse(self, txn_id: str) -> None: h = self._holds.pop(txn_id, None) if h is None: return with h.account.lock: h.account.held -= h.amount
# What can be spent right now: the balance minus what is already held. def available(self, card: Card) -> int: a = self._accounts[card.number] with a.lock: return a.balance - a.held// The complete design, built from every stage on this page.
using System.Collections.Concurrent;
// 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.public sealed 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.public interface IBank{ bool VerifyPin(Card card, string pin); bool 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.public interface ICashSlot{ bool Dispense(NotePlan plan); // false if the notes jammed}
// ids hands out unique transaction ids; injected so tests can name them.public sealed class Atm(IBank bank, CashDispenser dispenser, IJournal journal, Func<string> ids){ public const int MaxPinTries = 3;
private Card? _card; private int _pinTries;
public AtmState State { get; private set; } = AtmState.Idle;
public Result InsertCard(Card c) { if (State != AtmState.Idle) return Result.WrongState; _card = c; _pinTries = 0; State = AtmState.CardInserted; return Result.Ok; }
// Three wrong PINs in a row and the machine keeps the card, so a thief can't keep guessing. public Result EnterPin(string pin) { if (State != AtmState.CardInserted) return Result.WrongState; if (bank.VerifyPin(_card!, pin)) { State = AtmState.Authenticated; return Result.Ok; } if (++_pinTries >= MaxPinTries) { EndSession(); return Result.CardRetained; } return Result.WrongPin; }
// 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. public Result Withdraw(long amount) { if (State != AtmState.Authenticated) return Result.WrongState; if (dispenser.Plan(amount) is not { } plan) return Result.CannotDispense;
string txn = ids(); 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)) { bank.Reverse(txn); // the notes never left: release the hold journal.Record(txn, Step.Reversed); return Result.DispenseFailed; } 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. public List<string> Recover() { var suspect = new List<string>(); foreach (var (txn, step) in journal.LastSteps()) { switch (step) { // A hold may or may not exist. Reversing an id the bank never saw does nothing, // so reversing is always safe here. case Step.Started: bank.Reverse(txn); journal.Record(txn, Step.Reversed); break; // The notes are out; the customer has the cash. Make the debit final. case Step.Dispensed: bank.Capture(txn); journal.Record(txn, Step.Captured); break; // 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 Step.Dispensing: suspect.Add(txn); break; } } return suspect; }
public Result EjectCard() { if (State == AtmState.Idle) return Result.WrongState; 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; }}
// Where the session stands. Every operation is legal in exactly one of these.public enum AtmState { Idle, CardInserted, Authenticated }
// What each ATM action returns. CannotDispense means the machine can't make this amount from// its notes.public enum Result { Ok, WrongState, WrongPin, CardRetained, CannotDispense, Declined, DispenseFailed }
// The notes for one withdrawal: denomination -> how many. A value, so a plan can be made,// checked and logged before anything moves.public sealed record NotePlan(IReadOnlyDictionary<int, int> Notes){ public long Total => Notes.Sum(e => (long)e.Key * e.Value);}
// Knows what is in the cassettes and how to make an amount from it.public sealed class CashDispenser(IReadOnlyDictionary<int, int> loaded, ICashSlot slot){ private readonly Dictionary<int, int> _cassettes = new(loaded); // denomination -> notes left
// Notes that make the amount exactly from what is loaded, preferring large notes, or null. // 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. public NotePlan? Plan(long amount) { var denoms = _cassettes.Keys.OrderByDescending(d => d).ToList(); var dead = new HashSet<(int, long)>(); var chosen = new Dictionary<int, int>();
bool Search(int i, long left) { if (left == 0) return true; if (i == denoms.Count || dead.Contains((i, left))) return false; int d = denoms[i]; for (long n = Math.Min(_cassettes[d], left / d); n >= 0; n--) { if (n > 0) chosen[d] = (int)n; else chosen.Remove(d); if (Search(i + 1, left - n * d)) return true; } chosen.Remove(d); dead.Add((i, left)); // from this cassette onward, this remainder can't be made return false; }
return Search(0, amount) ? new NotePlan(chosen) : null; }
// 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. public bool Dispense(NotePlan p) { if (!slot.Dispense(p)) return false; foreach (var (d, n) in p.Notes) _cassettes[d] -= n; return true; }}
// How far a withdrawal got. The journal's last step for a transaction says what recovery owes.public 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.public interface IJournal{ void Record(string txnId, Step step); IReadOnlyList<(string TxnId, Step 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.public sealed class InMemoryJournal : IJournal{ private readonly List<string> _order = []; private readonly Dictionary<string, Step> _last = new();
public void Record(string txnId, Step step) { if (!_last.ContainsKey(txnId)) _order.Add(txnId); _last[txnId] = step; }
public IReadOnlyList<(string TxnId, Step Step)> LastSteps() => _order.Select(id => (id, _last[id])).ToList();}
// 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.public sealed class InMemoryBank : IBank{ // One customer account. held is money promised to withdrawals that haven't finished, so // what can be spent is balance minus held. private sealed class Account(string pin, long balance) { public readonly Lock Gate = new(); // one per account: other accounts never wait on this one public string Pin { get; } = pin; public long Balance = balance; public long Held; // reserved by authorized withdrawals not yet captured }
// A hold: which account and how much, so capture and reverse know what to settle. private sealed record Hold(Account Account, long Amount);
private readonly ConcurrentDictionary<string, Account> _accounts = new(); private readonly ConcurrentDictionary<string, Hold> _holds = new(); private readonly ConcurrentDictionary<string, bool> _decisions = new(); // txn id -> first answer
// Sets up an account. Tests call it; a real bank already has its accounts. public void Open(Card card, string pin, long balance) => _accounts[card.Number] = new Account(pin, balance);
// A real bank compares a hash inside an HSM. public bool VerifyPin(Card card, string pin) => _accounts.TryGetValue(card.Number, out var a) && a.Pin == pin;
public bool Authorize(string txnId, Card card, long amount) { if (!_accounts.TryGetValue(card.Number, out var a)) return false; // "Is there enough?" and "hold it" are one step under the account's lock, so two ATMs // can't both see 100 available and both hold it. lock (a.Gate) { if (_decisions.TryGetValue(txnId, out bool earlier)) return earlier; // a retry gets the first answer bool ok = a.Balance - a.Held >= amount; if (ok) { a.Held += amount; _holds[txnId] = new Hold(a, amount); } _decisions[txnId] = ok; return ok; } }
// Capture and Reverse both start by removing the hold. TryRemove 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) { if (!_holds.TryRemove(txnId, out var h)) return; lock (h.Account.Gate) { h.Account.Held -= h.Amount; h.Account.Balance -= h.Amount; } }
public void Reverse(string txnId) { if (!_holds.TryRemove(txnId, out var h)) return; lock (h.Account.Gate) h.Account.Held -= h.Amount; }
// What can be spent right now: the balance minus what is already held. public long Available(Card card) { var a = _accounts[card.Number]; lock (a.Gate) return a.Balance - a.Held; }}// The complete design, built from every stage on this page.
package atm
import ( "errors" "maps" "slices" "sync")
// Card is what the ATM reads from the 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.type Card struct { Number string}
// State is where the session stands. Every operation is legal in exactly one of these.type State int
const ( Idle State = iota CardInserted Authenticated)
// Every way an operation can be refused. A wrong PIN or an operation in the wrong state is// normal customer behavior, so each is a named error the screen shows.var ( ErrWrongState = errors.New("not allowed now") ErrWrongPin = errors.New("wrong PIN") ErrCardRetained = errors.New("card retained") ErrDeclined = errors.New("declined by the bank") ErrDispenseFailed = errors.New("could not dispense"))
const MaxPinTries = 3
// NotePlan is the notes for one withdrawal: denomination -> how many. A value, so a plan can// be made, checked and logged before anything moves.type NotePlan map[int]int
func (p NotePlan) Total() int64 { var t int64 for d, n := range p { t += int64(d * n) } return t}
// CashSlot is the cash hardware. It is told exactly which notes to hand out, not just an// amount.type CashSlot interface { Dispense(p NotePlan) bool // false if the notes jammed}
// CashDispenser knows what is in the cassettes and how to make an amount from it.type CashDispenser struct { cassettes map[int]int // denomination -> notes left slot CashSlot}
func NewCashDispenser(loaded map[int]int, slot CashSlot) *CashDispenser { return &CashDispenser{cassettes: maps.Clone(loaded), slot: slot}}
// Plan returns notes that make the amount exactly from what is loaded, preferring large// notes. 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.func (d *CashDispenser) Plan(amount int64) (NotePlan, bool) { denoms := slices.Sorted(maps.Keys(d.cassettes)) slices.Reverse(denoms) type state struct { i int left int64 } dead := map[state]bool{} chosen := NotePlan{} var search func(i int, left int64) bool search = func(i int, left int64) bool { if left == 0 { return true } if i == len(denoms) || dead[state{i, left}] { return false } den := denoms[i] for n := min(int64(d.cassettes[den]), left/int64(den)); n >= 0; n-- { if n > 0 { chosen[den] = int(n) } else { delete(chosen, den) } if search(i+1, left-n*int64(den)) { return true } } delete(chosen, den) dead[state{i, left}] = true // from this cassette onward, this remainder can't be made return false } if !search(0, amount) { return nil, false } return chosen, true}
// Dispense lowers the counts only after the notes are out. A jam leaves them unknown, which// the operator settles at the next refill by counting the reject bin.func (d *CashDispenser) Dispense(p NotePlan) bool { if !d.slot.Dispense(p) { return false } for den, n := range p { d.cassettes[den] -= n } return true}
// ErrCannotDispense means the machine can't make this amount from its notes.var ErrCannotDispense = errors.New("cannot dispense that amount")
// Bank is 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.type Bank interface { VerifyPin(card Card, pin string) bool Authorize(txnID string, card Card, amount int64) bool // place a hold, or refuse Capture(txnID string) // the cash is out: make it final Reverse(txnID string) // no cash: release the hold}
// Step is how far a withdrawal got. The journal's last step says what recovery owes.type Step int
const ( Started Step = iota Declined Dispensing Dispensed Captured Reversed)
// Journal is 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.type Journal interface { Record(txnID string, s Step) LastSteps() []TxnStep // each transaction's latest step, oldest first}
// TxnStep is one transaction's latest step, as recovery reads it.type TxnStep struct { TxnID string Step Step}
// MemoryJournal keeps the journal in memory, for tests. A real ATM writes it to storage that// survives a power cut. order keeps the oldest transaction first for recovery.type MemoryJournal struct { order []string last map[string]Step}
func NewMemoryJournal() *MemoryJournal { return &MemoryJournal{last: map[string]Step{}} }
func (j *MemoryJournal) Record(id string, s Step) { if _, seen := j.last[id]; !seen { j.order = append(j.order, id) } j.last[id] = s}
func (j *MemoryJournal) LastSteps() []TxnStep { out := make([]TxnStep, 0, len(j.order)) for _, id := range j.order { out = append(out, TxnStep{id, j.last[id]}) } return out}
// ATM is 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.type ATM struct { bank Bank dispenser *CashDispenser journal Journal ids func() string // unique transaction ids; injected so tests can name them State State card Card pinTries int}
func NewATM(bank Bank, d *CashDispenser, j Journal, ids func() string) *ATM { return &ATM{bank: bank, dispenser: d, journal: j, ids: ids}}
func (a *ATM) InsertCard(c Card) error { if a.State != Idle { return ErrWrongState } a.card, a.pinTries, a.State = c, 0, CardInserted return nil}
// EnterPin: three wrong PINs in a row and the machine keeps the card, so a thief can't keep// guessing.func (a *ATM) EnterPin(pin string) error { if a.State != CardInserted { return ErrWrongState } if a.bank.VerifyPin(a.card, pin) { a.State = Authenticated return nil } if a.pinTries++; a.pinTries >= MaxPinTries { a.endSession() return ErrCardRetained } return ErrWrongPin}
// Withdraw holds, dispenses, then captures or reverses. Each step is journaled before it// happens, so a power cut at any line leaves enough on record for Recover to finish the job.func (a *ATM) Withdraw(amount int64) error { if a.State != Authenticated { return ErrWrongState } plan, ok := a.dispenser.Plan(amount) if !ok { return ErrCannotDispense }
txn := a.ids() a.journal.Record(txn, Started) // before the bank hears of it if !a.bank.Authorize(txn, a.card, amount) { a.journal.Record(txn, Declined) return ErrDeclined } a.journal.Record(txn, Dispensing) if !a.dispenser.Dispense(plan) { a.bank.Reverse(txn) // the notes never left: release the hold a.journal.Record(txn, Reversed) return ErrDispenseFailed } a.journal.Record(txn, Dispensed) a.bank.Capture(txn) a.journal.Record(txn, Captured) return nil}
// Recover runs at power-up, before the first customer. It finishes or undoes whatever was in// flight, and returns the transactions only a person counting the cash can settle.func (a *ATM) Recover() []string { var suspect []string for _, e := range a.journal.LastSteps() { switch e.Step { case Started: // A hold may or may not exist. Reversing an id the bank never saw does nothing, // so reversing is always safe here. a.bank.Reverse(e.TxnID) a.journal.Record(e.TxnID, Reversed) case Dispensed: // The notes are out; the customer has the cash. Make the debit final. a.bank.Capture(e.TxnID) a.journal.Record(e.TxnID, Captured) case Dispensing: // 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. suspect = append(suspect, e.TxnID) } } return suspect}
func (a *ATM) EjectCard() error { if a.State == Idle { return ErrWrongState } a.endSession() return nil}
// endSession forgets the card and the PIN count, ready for the next customer.func (a *ATM) endSession() { a.card, a.pinTries, a.State = Card{}, 0, Idle }
// MemoryBank is 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.type MemoryBank struct { mu sync.Mutex // guards the maps below; each account has its own lock for its money accounts map[string]*account holds map[string]hold decisions map[string]bool // txn id -> first answer}
// account is one customer account. held is money promised to withdrawals that haven't finished,// so what can be spent is balance minus held.type account struct { mu sync.Mutex // one per account: other accounts never wait on this one pin string balance int64 held int64 // reserved by authorized withdrawals that are not captured yet}
// hold says which account and how much, so capture and reverse know what to settle.type hold struct { acct *account amount int64}
func NewMemoryBank() *MemoryBank { return &MemoryBank{accounts: map[string]*account{}, holds: map[string]hold{}, decisions: map[string]bool{}}}
// Open sets up an account. Tests call it; a real bank already has its accounts.func (b *MemoryBank) Open(c Card, pin string, balance int64) { b.mu.Lock() defer b.mu.Unlock() b.accounts[c.Number] = &account{pin: pin, balance: balance}}
// find looks up the account under the bank's lock, then lets it go: the money itself is guarded// by the account's own lock.func (b *MemoryBank) find(c Card) *account { b.mu.Lock() defer b.mu.Unlock() return b.accounts[c.Number]}
func (b *MemoryBank) VerifyPin(c Card, pin string) bool { a := b.find(c) return a != nil && a.pin == pin // a real bank compares a hash inside an HSM}
func (b *MemoryBank) Authorize(txnID string, c Card, amount int64) bool { a := b.find(c) if a == nil { return false } // "Is there enough?" and "hold it" are one step under the account's lock, so two ATMs // can't both see 100 available and both hold it. Lock order is always account, then // bank, so the two locks can't deadlock. a.mu.Lock() defer a.mu.Unlock() b.mu.Lock() defer b.mu.Unlock() if earlier, seen := b.decisions[txnID]; seen { return earlier // a retry gets the first answer, not a second hold } ok := a.balance-a.held >= amount if ok { a.held += amount b.holds[txnID] = hold{a, amount} } b.decisions[txnID] = ok return ok}
// take removes the hold under the bank's lock, so exactly one of Capture and Reverse, once,// gets it. A second capture, or a reverse after a capture, finds nothing and does nothing:// both are safe to retry.func (b *MemoryBank) take(txnID string) (hold, bool) { b.mu.Lock() defer b.mu.Unlock() h, ok := b.holds[txnID] delete(b.holds, txnID) return h, ok}
// Capture makes the debit final: the hold leaves held and the money leaves balance.func (b *MemoryBank) Capture(txnID string) { if h, ok := b.take(txnID); ok { h.acct.mu.Lock() h.acct.held -= h.amount h.acct.balance -= h.amount h.acct.mu.Unlock() }}
// Reverse releases the hold. The balance never changed, so only held goes down.func (b *MemoryBank) Reverse(txnID string) { if h, ok := b.take(txnID); ok { h.acct.mu.Lock() h.acct.held -= h.amount h.acct.mu.Unlock() }}
// Available is what can be spent right now: the balance minus what is already held.func (b *MemoryBank) Available(c Card) int64 { a := b.find(c) a.mu.Lock() defer a.mu.Unlock() return a.balance - a.held}// The complete design, built from every stage on this page.
// 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.interface Card { readonly number: string;}
// 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 { verifyPin(card: Card, pin: string): boolean; authorize(txnId: string, card: Card, amount: number): boolean; // place a hold, or refuse capture(txnId: string): void; // the cash is out: make it final reverse(txnId: string): void; // no cash: release the hold}
// The cash hardware. It is told exactly which notes to hand out, not just an amount.interface CashSlot { dispense(plan: NotePlan): boolean; // 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 { state: AtmState = 'idle'; private card: Card | null = null; private pinTries = 0;
constructor( private readonly bank: Bank, private readonly dispenser: CashDispenser, private readonly journal: Journal, private readonly ids: () => string, // unique transaction ids; injected so tests can name them ) {}
insertCard(c: Card): Result { if (this.state !== 'idle') return 'wrong-state'; this.card = c; this.pinTries = 0; this.state = 'card-inserted'; return 'ok'; }
// Three wrong PINs in a row and the machine keeps the card, so a thief can't keep guessing. enterPin(pin: string): Result { if (this.state !== 'card-inserted' || !this.card) return 'wrong-state'; if (this.bank.verifyPin(this.card, pin)) { this.state = 'authenticated'; return 'ok'; } if (++this.pinTries >= MAX_PIN_TRIES) { this.endSession(); return 'card-retained'; } return '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. withdraw(amount: number): Result { if (this.state !== 'authenticated' || !this.card) return 'wrong-state'; const plan = this.dispenser.plan(amount); if (!plan) return 'cannot-dispense';
const txn = this.ids(); this.journal.record(txn, 'started'); // before the bank hears of it if (!this.bank.authorize(txn, this.card, amount)) { this.journal.record(txn, 'declined'); return 'declined'; } this.journal.record(txn, 'dispensing'); if (!this.dispenser.dispense(plan)) { this.bank.reverse(txn); // the notes never left: release the hold this.journal.record(txn, 'reversed'); return 'dispense-failed'; } this.journal.record(txn, 'dispensed'); this.bank.capture(txn); this.journal.record(txn, 'captured'); return '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. recover(): string[] { const suspect: string[] = []; for (const [txn, step] of this.journal.lastSteps()) { switch (step) { case 'started': // A hold may or may not exist. Reversing an id the bank never saw does nothing, // so reversing is always safe here. this.bank.reverse(txn); this.journal.record(txn, 'reversed'); break; case 'dispensed': // The notes are out; the customer has the cash. Make the debit final. this.bank.capture(txn); this.journal.record(txn, 'captured'); break; case 'dispensing': // 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. suspect.push(txn); break; } } return suspect; }
ejectCard(): Result { if (this.state === 'idle') return 'wrong-state'; this.endSession(); return 'ok'; }
// Forget the card and the PIN count, ready for the next customer. private endSession(): void { this.card = null; this.pinTries = 0; this.state = 'idle'; }}
// Where the session stands. Every operation is legal in exactly one of these.type AtmState = 'idle' | 'card-inserted' | 'authenticated';
// What each ATM action returns. 'cannot-dispense' means the machine can't make this amount from// its notes.type Result = 'ok' | 'wrong-state' | 'wrong-pin' | 'card-retained' | 'cannot-dispense' | 'declined' | 'dispense-failed';
const MAX_PIN_TRIES = 3;
// The notes for one withdrawal: denomination -> how many. A value, so a plan can be made,// checked and logged before anything moves.type NotePlan = ReadonlyMap<number, number>;
const total = (p: NotePlan) => [...p].reduce((t, [d, n]) => t + d * n, 0);
// Knows what is in the cassettes and how to make an amount from it.class CashDispenser { private readonly cassettes: Map<number, number>; // denomination -> notes left
constructor( loaded: ReadonlyMap<number, number>, private readonly slot: CashSlot, ) { this.cassettes = new Map(loaded); }
// Notes that make the amount exactly from what is loaded, preferring large notes, or null. // 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. plan(amount: number): NotePlan | null { const denoms = [...this.cassettes.keys()].sort((a, b) => b - a); const dead = new Set<string>(); const chosen = new Map<number, number>(); const search = (i: number, left: number): boolean => { if (left === 0) return true; if (i === denoms.length || dead.has(`${i}:${left}`)) return false; const d = denoms[i]; for (let n = Math.min(this.cassettes.get(d)!, Math.floor(left / d)); n >= 0; n--) { if (n > 0) chosen.set(d, n); else chosen.delete(d); if (search(i + 1, left - n * d)) return true; } chosen.delete(d); dead.add(`${i}:${left}`); // from this cassette onward, this remainder can't be made return false; }; return search(0, amount) ? chosen : null; }
// 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. dispense(p: NotePlan): boolean { if (!this.slot.dispense(p)) return false; for (const [d, n] of p) this.cassettes.set(d, this.cassettes.get(d)! - n); return true; }}
// How far a withdrawal got. The journal's last step for a transaction says what recovery owes.type 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 { record(txnId: string, step: Step): void; lastSteps(): ReadonlyMap<string, Step>; // 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 readonly last = new Map<string, Step>(); // Maps keep insertion order
record(txnId: string, step: Step): void { this.last.set(txnId, step); }
lastSteps(): ReadonlyMap<string, Step> { return new Map(this.last); }}
// One customer account. held is money promised to withdrawals that haven't finished, so what// can be spent is balance minus held.interface Account { readonly pin: string; balance: number; held: number; // reserved by authorized withdrawals that are not captured yet}
// The bank side, where the real race is: two ATMs, one joint account, the last 100.//// No locks: in one Node process, authorize() runs to the end before any other request// starts, so "is there enough?" and "hold it" can't be split. That stops being true the// moment the account lives in a database. Then the check and the hold must be one// conditional statement, e.g.// UPDATE accounts SET held = held + :amt WHERE id = :id AND balance - held >= :amt// and zero rows updated means declined.class InMemoryBank implements Bank { private readonly accounts = new Map<string, Account>(); private readonly holds = new Map<string, { account: Account; amount: number }>(); private readonly decisions = new Map<string, boolean>(); // txn id -> first answer
// Sets up an account. Tests call it; a real bank already has its accounts. open(card: Card, pin: string, balance: number): void { this.accounts.set(card.number, { pin, balance, held: 0 }); }
verifyPin(card: Card, pin: string): boolean { return this.accounts.get(card.number)?.pin === pin; // a real bank compares a hash inside an HSM }
authorize(txnId: string, card: Card, amount: number): boolean { const a = this.accounts.get(card.number); if (!a) return false; const earlier = this.decisions.get(txnId); if (earlier !== undefined) return earlier; // a retry gets the first answer, not a second hold const ok = a.balance - a.held >= amount; if (ok) { a.held += amount; this.holds.set(txnId, { account: a, amount }); } this.decisions.set(txnId, ok); return ok; }
// capture and reverse both start by removing the hold, so exactly one of them, once, gets // it. A second capture, or a reverse after a capture, finds nothing and does nothing. capture(txnId: string): void { const h = this.holds.get(txnId); if (!h) return; this.holds.delete(txnId); h.account.held -= h.amount; h.account.balance -= h.amount; }
reverse(txnId: string): void { const h = this.holds.get(txnId); if (!h) return; this.holds.delete(txnId); h.account.held -= h.amount; }
// What can be spent right now: the balance minus what is already held. available(card: Card): number { const a = this.accounts.get(card.number)!; return a.balance - a.held; }}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.
Atmcoordinates them and keeps none of their data. To the screen,Atmis 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.
Stress it
Section titled “Stress it”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.
Two ATMs, one account, the last 100
Section titled “Two ATMs, one account, the last 100”A couple share an account with 100 in it. They each withdraw 100, at different ATMs, at the same second. What happens?
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.
- Color key
- Interface
// 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; } }}import threadingfrom dataclasses import dataclass
# One customer account. held is money promised to withdrawals that haven't finished, so what# can be spent is balance minus held.class _Account: def __init__(self, pin: str, balance: int): self.pin = pin self.balance = balance self.held = 0 # reserved by authorized withdrawals that are not captured yet self.lock = threading.Lock() # one per account: other accounts never wait on this one
# A hold: which account and how much, so capture and reverse know what to settle.@dataclass(frozen=True)class _Hold: account: _Account amount: int
# 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: def __init__(self): self._accounts: dict[str, _Account] = {} self._holds: dict[str, _Hold] = {} self._decisions: dict[str, bool] = {} # txn id -> first answer
# Sets up an account. Tests call it; a real bank already has its accounts. def open(self, card: Card, pin: str, balance: int) -> None: self._accounts[card.number] = _Account(pin, balance)
def verify_pin(self, card: Card, pin: str) -> bool: a = self._accounts.get(card.number) return a is not None and a.pin == pin # a real bank compares a hash inside an HSM
def authorize(self, txn_id: str, card: Card, amount: int) -> bool: a = self._accounts.get(card.number) if a is None: return False # "Is there enough?" and "hold it" are one step under the account's lock, so two ATMs # can't both see 100 available and both hold it. with a.lock: if txn_id in self._decisions: return self._decisions[txn_id] # a retry gets the first answer, not a second hold ok = a.balance - a.held >= amount if ok: a.held += amount self._holds[txn_id] = _Hold(a, amount) self._decisions[txn_id] = ok return ok
# capture and reverse both start by removing the hold. dict.pop is atomic in CPython, 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. def capture(self, txn_id: str) -> None: h = self._holds.pop(txn_id, None) if h is None: return with h.account.lock: h.account.held -= h.amount h.account.balance -= h.amount
def reverse(self, txn_id: str) -> None: h = self._holds.pop(txn_id, None) if h is None: return with h.account.lock: h.account.held -= h.amount
# What can be spent right now: the balance minus what is already held. def available(self, card: Card) -> int: a = self._accounts[card.number] with a.lock: return a.balance - a.held// 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.public sealed class InMemoryBank : IBank{ // One customer account. held is money promised to withdrawals that haven't finished, so // what can be spent is balance minus held. private sealed class Account(string pin, long balance) { public readonly Lock Gate = new(); // one per account: other accounts never wait on this one public string Pin { get; } = pin; public long Balance = balance; public long Held; // reserved by authorized withdrawals not yet captured }
// A hold: which account and how much, so capture and reverse know what to settle. private sealed record Hold(Account Account, long Amount);
private readonly ConcurrentDictionary<string, Account> _accounts = new(); private readonly ConcurrentDictionary<string, Hold> _holds = new(); private readonly ConcurrentDictionary<string, bool> _decisions = new(); // txn id -> first answer
// Sets up an account. Tests call it; a real bank already has its accounts. public void Open(Card card, string pin, long balance) => _accounts[card.Number] = new Account(pin, balance);
// A real bank compares a hash inside an HSM. public bool VerifyPin(Card card, string pin) => _accounts.TryGetValue(card.Number, out var a) && a.Pin == pin;
public bool Authorize(string txnId, Card card, long amount) { if (!_accounts.TryGetValue(card.Number, out var a)) return false; // "Is there enough?" and "hold it" are one step under the account's lock, so two ATMs // can't both see 100 available and both hold it. lock (a.Gate) { if (_decisions.TryGetValue(txnId, out bool earlier)) return earlier; // a retry gets the first answer bool ok = a.Balance - a.Held >= amount; if (ok) { a.Held += amount; _holds[txnId] = new Hold(a, amount); } _decisions[txnId] = ok; return ok; } }
// Capture and Reverse both start by removing the hold. TryRemove 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) { if (!_holds.TryRemove(txnId, out var h)) return; lock (h.Account.Gate) { h.Account.Held -= h.Amount; h.Account.Balance -= h.Amount; } }
public void Reverse(string txnId) { if (!_holds.TryRemove(txnId, out var h)) return; lock (h.Account.Gate) h.Account.Held -= h.Amount; }
// What can be spent right now: the balance minus what is already held. public long Available(Card card) { var a = _accounts[card.Number]; lock (a.Gate) return a.Balance - a.Held; }}// MemoryBank is 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.type MemoryBank struct { mu sync.Mutex // guards the maps below; each account has its own lock for its money accounts map[string]*account holds map[string]hold decisions map[string]bool // txn id -> first answer}
// account is one customer account. held is money promised to withdrawals that haven't finished,// so what can be spent is balance minus held.type account struct { mu sync.Mutex // one per account: other accounts never wait on this one pin string balance int64 held int64 // reserved by authorized withdrawals that are not captured yet}
// hold says which account and how much, so capture and reverse know what to settle.type hold struct { acct *account amount int64}
func NewMemoryBank() *MemoryBank { return &MemoryBank{accounts: map[string]*account{}, holds: map[string]hold{}, decisions: map[string]bool{}}}
// Open sets up an account. Tests call it; a real bank already has its accounts.func (b *MemoryBank) Open(c Card, pin string, balance int64) { b.mu.Lock() defer b.mu.Unlock() b.accounts[c.Number] = &account{pin: pin, balance: balance}}
// find looks up the account under the bank's lock, then lets it go: the money itself is guarded// by the account's own lock.func (b *MemoryBank) find(c Card) *account { b.mu.Lock() defer b.mu.Unlock() return b.accounts[c.Number]}
func (b *MemoryBank) VerifyPin(c Card, pin string) bool { a := b.find(c) return a != nil && a.pin == pin // a real bank compares a hash inside an HSM}
func (b *MemoryBank) Authorize(txnID string, c Card, amount int64) bool { a := b.find(c) if a == nil { return false } // "Is there enough?" and "hold it" are one step under the account's lock, so two ATMs // can't both see 100 available and both hold it. Lock order is always account, then // bank, so the two locks can't deadlock. a.mu.Lock() defer a.mu.Unlock() b.mu.Lock() defer b.mu.Unlock() if earlier, seen := b.decisions[txnID]; seen { return earlier // a retry gets the first answer, not a second hold } ok := a.balance-a.held >= amount if ok { a.held += amount b.holds[txnID] = hold{a, amount} } b.decisions[txnID] = ok return ok}
// take removes the hold under the bank's lock, so exactly one of Capture and Reverse, once,// gets it. A second capture, or a reverse after a capture, finds nothing and does nothing:// both are safe to retry.func (b *MemoryBank) take(txnID string) (hold, bool) { b.mu.Lock() defer b.mu.Unlock() h, ok := b.holds[txnID] delete(b.holds, txnID) return h, ok}
// Capture makes the debit final: the hold leaves held and the money leaves balance.func (b *MemoryBank) Capture(txnID string) { if h, ok := b.take(txnID); ok { h.acct.mu.Lock() h.acct.held -= h.amount h.acct.balance -= h.amount h.acct.mu.Unlock() }}
// Reverse releases the hold. The balance never changed, so only held goes down.func (b *MemoryBank) Reverse(txnID string) { if h, ok := b.take(txnID); ok { h.acct.mu.Lock() h.acct.held -= h.amount h.acct.mu.Unlock() }}
// Available is what can be spent right now: the balance minus what is already held.func (b *MemoryBank) Available(c Card) int64 { a := b.find(c) a.mu.Lock() defer a.mu.Unlock() return a.balance - a.held}// One customer account. held is money promised to withdrawals that haven't finished, so what// can be spent is balance minus held.interface Account { readonly pin: string; balance: number; held: number; // reserved by authorized withdrawals that are not captured yet}
// The bank side, where the real race is: two ATMs, one joint account, the last 100.//// No locks: in one Node process, authorize() runs to the end before any other request// starts, so "is there enough?" and "hold it" can't be split. That stops being true the// moment the account lives in a database. Then the check and the hold must be one// conditional statement, e.g.// UPDATE accounts SET held = held + :amt WHERE id = :id AND balance - held >= :amt// and zero rows updated means declined.class InMemoryBank implements Bank { private readonly accounts = new Map<string, Account>(); private readonly holds = new Map<string, { account: Account; amount: number }>(); private readonly decisions = new Map<string, boolean>(); // txn id -> first answer
// Sets up an account. Tests call it; a real bank already has its accounts. open(card: Card, pin: string, balance: number): void { this.accounts.set(card.number, { pin, balance, held: 0 }); }
verifyPin(card: Card, pin: string): boolean { return this.accounts.get(card.number)?.pin === pin; // a real bank compares a hash inside an HSM }
authorize(txnId: string, card: Card, amount: number): boolean { const a = this.accounts.get(card.number); if (!a) return false; const earlier = this.decisions.get(txnId); if (earlier !== undefined) return earlier; // a retry gets the first answer, not a second hold const ok = a.balance - a.held >= amount; if (ok) { a.held += amount; this.holds.set(txnId, { account: a, amount }); } this.decisions.set(txnId, ok); return ok; }
// capture and reverse both start by removing the hold, so exactly one of them, once, gets // it. A second capture, or a reverse after a capture, finds nothing and does nothing. capture(txnId: string): void { const h = this.holds.get(txnId); if (!h) return; this.holds.delete(txnId); h.account.held -= h.amount; h.account.balance -= h.amount; }
reverse(txnId: string): void { const h = this.holds.get(txnId); if (!h) return; this.holds.delete(txnId); h.account.held -= h.amount; }
// What can be spent right now: the balance minus what is already held. available(card: Card): number { const a = this.accounts.get(card.number)!; 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.
How you’d prove it works
Section titled “How you’d prove it works”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.
// 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;}
@Testvoid withdrawingBeforeThePinIsRefused() { Atm a = atm(); a.insertCard(CARD); assertEquals(Result.WRONG_STATE, a.withdraw(100)); assertEquals(500, bank.available(CARD));}
@Testvoid 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());}
@Testvoid 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());}
@Testvoid 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}
@Testvoid 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"));}
@Testvoid aWithdrawalHoldsThenCaptures() { assertEquals(Result.OK, signedIn().withdraw(120)); assertEquals(380, bank.available(CARD)); assertEquals(120, slot.delivered.get(0).total());}
@Testvoid 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}
@Testvoid 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));}
@Testvoid 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));}import threadingfrom concurrent.futures import ThreadPoolExecutor
import pytest
# Records what the cash slot was asked to do, and can be told to jam.class FakeSlot: def __init__(self): self.jam = False self.delivered: list[NotePlan] = []
def dispense(self, p: NotePlan) -> bool: if self.jam: return False self.delivered.append(p) return True
CARD = Card("4000-0001")
@pytest.fixturedef world(): bank, slot, journal = InMemoryBank(), FakeSlot(), InMemoryJournal() ids = (f"t{i}" for i in range(1000)) # Fifty 20s and twenty 50s; the account holds 500; the PIN is 1234. bank.open(CARD, "1234", 500) atm = Atm(bank, CashDispenser({50: 20, 20: 50}, slot), journal, lambda: next(ids)) return bank, slot, journal, atm
def signed_in(atm: Atm) -> Atm: atm.insert_card(CARD) atm.enter_pin("1234") return atm
def test_withdrawing_before_the_pin_is_refused(world): bank, _, _, atm = world atm.insert_card(CARD) assert atm.withdraw(100) is Result.WRONG_STATE assert bank.available(CARD) == 500
def test_three_wrong_pins_keep_the_card(world): _, _, _, atm = world atm.insert_card(CARD) assert atm.enter_pin("0000") is Result.WRONG_PIN assert atm.enter_pin("1111") is Result.WRONG_PIN assert atm.enter_pin("2222") is Result.CARD_RETAINED assert atm.state is AtmState.IDLE
def test_the_note_plan_finds_change_where_greedy_gets_stuck(): d = CashDispenser({500: 1, 200: 3}, FakeSlot()) assert d.plan(600).notes == {200: 3} # greedy takes the 500, then is stuck assert d.plan(100) is None
def test_an_amount_the_machine_cant_make_never_reaches_the_bank(world): _, _, journal, atm = world assert signed_in(atm).withdraw(135) is Result.CANNOT_DISPENSE # no mix of 20s and 50s makes 135 assert journal.last_steps() == {} # no transaction was even started
def test_a_jam_releases_the_hold(world): bank, slot, journal, atm = world signed_in(atm) slot.jam = True assert atm.withdraw(100) is Result.DISPENSE_FAILED assert bank.available(CARD) == 500 # the customer paid nothing assert journal.last_steps()["t0"] is Step.REVERSED
def test_a_withdrawal_holds_then_captures(world): bank, slot, _, atm = world assert signed_in(atm).withdraw(120) is Result.OK assert bank.available(CARD) == 380 assert slot.delivered[0].total() == 120
def test_recovery_finishes_what_a_power_cut_interrupted(world): bank, slot, journal, _ = world 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 restarted = Atm(bank, CashDispenser({20: 50}, slot), journal, lambda: "x") assert restarted.recover() == ["mid"] # only the unknowable one goes to a person assert bank.available(CARD) == 450 # "held" released, "paid" captured
def test_a_retried_authorize_is_one_hold(world): bank, _, _, _ = world assert bank.authorize("t9", CARD, 300) assert bank.authorize("t9", CARD, 300) # the response was lost; the ATM asks again assert bank.available(CARD) == 200
def test_two_atms_racing_for_the_last_money_only_one_wins(): bank = InMemoryBank() bank.open(CARD, "1234", 100) start = threading.Barrier(16) # releases once all sixteen threads are waiting
def attempt(i: int) -> bool: start.wait() return bank.authorize(f"race{i}", CARD, 100)
with ThreadPoolExecutor(max_workers=16) as atms: approved = sum(atms.map(attempt, range(16))) assert approved == 1 assert bank.available(CARD) == 0// Records what the cash slot was asked to do, and can be told to jam.private sealed class FakeSlot : ICashSlot{ public bool Jam; public readonly List<NotePlan> Delivered = []; public bool Dispense(NotePlan p) { if (Jam) return false; Delivered.Add(p); return true; }}
private static readonly Card TestCard = new("4000-0001");
private readonly InMemoryBank _bank = new();private readonly FakeSlot _slot = new();private readonly InMemoryJournal _journal = new();private int _nextId;
// Fifty 20s and twenty 50s; the account holds 500; the PIN is 1234.private Atm NewAtm(){ _bank.Open(TestCard, "1234", 500); var notes = new Dictionary<int, int> { [50] = 20, [20] = 50 }; return new Atm(_bank, new CashDispenser(notes, _slot), _journal, () => $"t{_nextId++}");}
private Atm SignedIn(){ var a = NewAtm(); a.InsertCard(TestCard); a.EnterPin("1234"); return a;}
[Fact]public void WithdrawingBeforeThePinIsRefused(){ var a = NewAtm(); a.InsertCard(TestCard); Assert.Equal(Result.WrongState, a.Withdraw(100)); Assert.Equal(500, _bank.Available(TestCard));}
[Fact]public void ThreeWrongPinsKeepTheCard(){ var a = NewAtm(); a.InsertCard(TestCard); Assert.Equal(Result.WrongPin, a.EnterPin("0000")); Assert.Equal(Result.WrongPin, a.EnterPin("1111")); Assert.Equal(Result.CardRetained, a.EnterPin("2222")); Assert.Equal(AtmState.Idle, a.State);}
[Fact]public void TheNotePlanFindsChangeWhereGreedyGetsStuck(){ var d = new CashDispenser(new Dictionary<int, int> { [500] = 1, [200] = 3 }, _slot); var plan = d.Plan(600); // greedy takes the 500, then is stuck Assert.NotNull(plan); Assert.Equal(3, plan.Notes[200]); Assert.False(plan.Notes.ContainsKey(500)); Assert.Null(d.Plan(100));}
[Fact]public void AnAmountTheMachineCantMakeNeverReachesTheBank(){ Assert.Equal(Result.CannotDispense, SignedIn().Withdraw(135)); // no mix of 20s and 50s makes 135 Assert.Empty(_journal.LastSteps()); // no transaction was even started}
[Fact]public void AJamReleasesTheHold(){ var a = SignedIn(); _slot.Jam = true; Assert.Equal(Result.DispenseFailed, a.Withdraw(100)); Assert.Equal(500, _bank.Available(TestCard)); // the customer paid nothing}
[Fact]public void AWithdrawalHoldsThenCaptures(){ Assert.Equal(Result.Ok, SignedIn().Withdraw(120)); Assert.Equal(380, _bank.Available(TestCard)); Assert.Equal(120, _slot.Delivered[0].Total);}
[Fact]public void RecoveryFinishesWhatAPowerCutInterrupted(){ NewAtm(); _bank.Authorize("held", TestCard, 100); // the power went after the hold... _journal.Record("held", Step.Started); _bank.Authorize("paid", TestCard, 50); // ...after the cash came out... _journal.Record("paid", Step.Dispensed); _journal.Record("mid", Step.Dispensing); // ...and in the middle of dispensing var restarted = new Atm(_bank, new CashDispenser(new Dictionary<int, int> { [20] = 50 }, _slot), _journal, () => "x"); Assert.Equal(new[] { "mid" }, restarted.Recover()); // only the unknowable one goes to a person Assert.Equal(450, _bank.Available(TestCard)); // "held" released, "paid" captured}
[Fact]public void ARetriedAuthorizeIsOneHold(){ NewAtm(); Assert.True(_bank.Authorize("t9", TestCard, 300)); Assert.True(_bank.Authorize("t9", TestCard, 300)); // the response was lost; the ATM asks again Assert.Equal(200, _bank.Available(TestCard));}
[Fact]public async Task TwoAtmsRacingForTheLastMoneyOnlyOneWins(){ _bank.Open(TestCard, "1234", 100); var start = new TaskCompletionSource(); var tries = Enumerable.Range(0, 16) .Select(i => Task.Run(async () => { await start.Task; return _bank.Authorize($"race{i}", TestCard, 100); })) .ToArray(); start.SetResult(); // release all sixteen at once var results = await Task.WhenAll(tries); Assert.Single(results, ok => ok); Assert.Equal(0, _bank.Available(TestCard));}// fakeSlot records what the cash slot was asked to do, and can be told to jam.type fakeSlot struct { jam bool delivered []NotePlan}
func (s *fakeSlot) Dispense(p NotePlan) bool { if s.jam { return false } s.delivered = append(s.delivered, p) return true}
var card = Card{"4000-0001"}
type world struct { bank *MemoryBank slot *fakeSlot journal *MemoryJournal atm *ATM}
// newWorld loads fifty 20s and twenty 50s; the account holds 500; the PIN is 1234.func newWorld() *world { w := &world{bank: NewMemoryBank(), slot: &fakeSlot{}, journal: NewMemoryJournal()} w.bank.Open(card, "1234", 500) next := 0 ids := func() string { next++; return fmt.Sprintf("t%d", next-1) } w.atm = NewATM(w.bank, NewCashDispenser(map[int]int{50: 20, 20: 50}, w.slot), w.journal, ids) return w}
func (w *world) signIn() *ATM { w.atm.InsertCard(card) w.atm.EnterPin("1234") return w.atm}
func TestWithdrawingBeforeThePinIsRefused(t *testing.T) { w := newWorld() w.atm.InsertCard(card) if err := w.atm.Withdraw(100); err != ErrWrongState || w.bank.Available(card) != 500 { t.Fatalf("err %v, available %d", err, w.bank.Available(card)) }}
func TestThreeWrongPinsKeepTheCard(t *testing.T) { w := newWorld() w.atm.InsertCard(card) w.atm.EnterPin("0000") w.atm.EnterPin("1111") if err := w.atm.EnterPin("2222"); err != ErrCardRetained || w.atm.State != Idle { t.Fatalf("err %v, state %v", err, w.atm.State) }}
func TestTheNotePlanFindsChangeWhereGreedyGetsStuck(t *testing.T) { d := NewCashDispenser(map[int]int{500: 1, 200: 3}, &fakeSlot{}) if p, ok := d.Plan(600); !ok || !maps.Equal(p, NotePlan{200: 3}) { // greedy takes the 500, then is stuck t.Fatalf("plan %v", p) } if _, ok := d.Plan(100); ok { t.Fatal("100 can't be made from 500s and 200s") }}
func TestAnAmountTheMachineCantMakeNeverReachesTheBank(t *testing.T) { w := newWorld() if err := w.signIn().Withdraw(135); err != ErrCannotDispense { // no mix of 20s and 50s makes 135 t.Fatal(err) } if len(w.journal.LastSteps()) != 0 { // no transaction was even started t.Fatal("journal not empty") }}
func TestAJamReleasesTheHold(t *testing.T) { w := newWorld() w.signIn() w.slot.jam = true if err := w.atm.Withdraw(100); err != ErrDispenseFailed { t.Fatal(err) } if w.bank.Available(card) != 500 { // the customer paid nothing t.Fatalf("available %d", w.bank.Available(card)) }}
func TestAWithdrawalHoldsThenCaptures(t *testing.T) { w := newWorld() if err := w.signIn().Withdraw(120); err != nil || w.bank.Available(card) != 380 || w.slot.delivered[0].Total() != 120 { t.Fatalf("err %v, available %d", err, w.bank.Available(card)) }}
func TestRecoveryFinishesWhatAPowerCutInterrupted(t *testing.T) { w := newWorld() w.bank.Authorize("held", card, 100) // the power went after the hold... w.journal.Record("held", Started) w.bank.Authorize("paid", card, 50) // ...after the cash came out... w.journal.Record("paid", Dispensed) w.journal.Record("mid", Dispensing) // ...and in the middle of dispensing restarted := NewATM(w.bank, NewCashDispenser(map[int]int{20: 50}, w.slot), w.journal, func() string { return "x" }) if s := restarted.Recover(); !slices.Equal(s, []string{"mid"}) { // only the unknowable one goes to a person t.Fatalf("suspect %v", s) } if w.bank.Available(card) != 450 { // "held" released, "paid" captured t.Fatalf("available %d", w.bank.Available(card)) }}
func TestARetriedAuthorizeIsOneHold(t *testing.T) { w := newWorld() w.bank.Authorize("t9", card, 300) w.bank.Authorize("t9", card, 300) // the response was lost; the ATM asks again if w.bank.Available(card) != 200 { t.Fatalf("available %d", w.bank.Available(card)) }}
// Run with: go test -race ./...func TestTwoATMsRacingForTheLastMoneyOnlyOneWins(t *testing.T) { b := NewMemoryBank() b.Open(card, "1234", 100) var approved atomic.Int32 var wg sync.WaitGroup start := make(chan struct{}) for i := range 16 { wg.Add(1) go func() { defer wg.Done() <-start if b.Authorize(fmt.Sprintf("race%d", i), card, 100) { approved.Add(1) } }() } close(start) // release all sixteen at once wg.Wait() if approved.Load() != 1 || b.Available(card) != 0 { t.Fatalf("%d approved, %d available", approved.Load(), b.Available(card)) }}import { test } from 'node:test';import assert from 'node:assert/strict';
// Records what the cash slot was asked to do, and can be told to jam.class FakeSlot implements CashSlot { jam = false; delivered: NotePlan[] = []; dispense(p: NotePlan): boolean { if (this.jam) return false; this.delivered.push(p); return true; }}
const CARD: Card = { number: '4000-0001' };
// Fifty 20s and twenty 50s; the account holds 500; the PIN is 1234.function world() { const bank = new InMemoryBank(); const slot = new FakeSlot(); const journal = new InMemoryJournal(); let next = 0; bank.open(CARD, '1234', 500); const notes = new Map([ [50, 20], [20, 50], ]); const atm = new Atm(bank, new CashDispenser(notes, slot), journal, () => `t${next++}`); return { bank, slot, journal, atm };}
function signedIn(atm: Atm): Atm { atm.insertCard(CARD); atm.enterPin('1234'); return atm;}
test('withdrawing before the PIN is refused', () => { const { bank, atm } = world(); atm.insertCard(CARD); assert.equal(atm.withdraw(100), 'wrong-state'); assert.equal(bank.available(CARD), 500);});
test('three wrong PINs keep the card', () => { const { atm } = world(); atm.insertCard(CARD); assert.equal(atm.enterPin('0000'), 'wrong-pin'); assert.equal(atm.enterPin('1111'), 'wrong-pin'); assert.equal(atm.enterPin('2222'), 'card-retained'); assert.equal(atm.state, 'idle');});
test('the note plan finds change where greedy gets stuck', () => { const d = new CashDispenser( new Map([ [500, 1], [200, 3], ]), new FakeSlot(), ); assert.deepEqual(d.plan(600), new Map([[200, 3]])); // greedy takes the 500, then is stuck assert.equal(d.plan(100), null);});
test("an amount the machine can't make never reaches the bank", () => { const { journal, atm } = world(); assert.equal(signedIn(atm).withdraw(135), 'cannot-dispense'); // no mix of 20s and 50s makes 135 assert.equal(journal.lastSteps().size, 0); // no transaction was even started});
test('a jam releases the hold', () => { const { bank, slot, journal, atm } = world(); signedIn(atm); slot.jam = true; assert.equal(atm.withdraw(100), 'dispense-failed'); assert.equal(bank.available(CARD), 500); // the customer paid nothing assert.equal(journal.lastSteps().get('t0'), 'reversed');});
test('a withdrawal holds, then captures', () => { const { bank, slot, atm } = world(); assert.equal(signedIn(atm).withdraw(120), 'ok'); assert.equal(bank.available(CARD), 380); assert.equal(total(slot.delivered[0]), 120);});
test('recovery finishes what a power cut interrupted', () => { const { bank, slot, journal } = world(); bank.authorize('held', CARD, 100); // the power went after the hold... journal.record('held', 'started'); bank.authorize('paid', CARD, 50); // ...after the cash came out... journal.record('paid', 'dispensed'); journal.record('mid', 'dispensing'); // ...and in the middle of dispensing const restarted = new Atm(bank, new CashDispenser(new Map([[20, 50]]), slot), journal, () => 'x'); assert.deepEqual(restarted.recover(), ['mid']); // only the unknowable one goes to a person assert.equal(bank.available(CARD), 450); // "held" released, "paid" captured});
test('a retried authorize is one hold', () => { const { bank } = world(); assert.ok(bank.authorize('t9', CARD, 300)); assert.ok(bank.authorize('t9', CARD, 300)); // the response was lost; the ATM asks again assert.equal(bank.available(CARD), 200);});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 |
Wrap up
Section titled “Wrap up”The decisions, and what each one costs
Section titled “The decisions, and what each one costs”| 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.
How to spend 45 minutes
Section titled “How to spend 45 minutes”- 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
withdrawandrecover, 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.
Follow-up questions
Section titled “Follow-up questions”Add deposits. Customers put in a stack of notes, and the machine counts them.
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?
- 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
COUNTEDwith the amount. - 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.
- The bank credits. The ATM calls
creditwith a transaction ID, so a retry can’t credit twice. The journal recordsCREDITED. - 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.
Further reading
Section titled “Further reading”- State pattern, Strategy pattern
- Design a vending machine, the same cash problems with coins, escrow and a single event loop
- System design: distributed transactions, where the saga shape comes from
- Wikipedia: Write-ahead logging, the idea behind the journal
- Chris Richardson: Saga pattern, hold and capture as compensating steps
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.