Design an ATM (LLD)
A state machine for the card and PIN flow, a chain of note handlers that backtracks on limited stock, and a withdrawal that never charges without paying.
- 3 x 5 cellsthree states, five events, one outcome each
- 3 trieswrong PINs before the account locks
- 60 = 3 x 20what greedy misses with one 50 left
Show what to say
“An ATM is a state machine: Idle, CardIn and Authenticated, with a table that gives every event one outcome and rejects the rest. Cash comes from a chain of note handlers that backtracks, so limited stock never strands a remainder. I plan the notes first, then debit under the account lock, then dispense, and credit back if the cash jams.”
Trade-offs
- Table vs flags: every cell is visible and unlisted means rejected, against a little more structure.
- Chain vs loop: a chain lets each note own rules, but four fixed notes need only a loop with backtracking.
- Undo vs hold and commit: crediting back is simple, while a hold survives a crash between debit and dispense.
Bring it up before they ask
The failed-PIN count must live on the account, not the session, or an attacker gets three guesses per machine and per card insert.
An ATM is a state machine (card out, card in, PIN passed) with one table that gives every event one outcome in every state. Cash comes from a chain of note handlers, and the account is debited only after a note plan exists.
Functional
- Insert a card, enter a PIN, then check balance or withdraw
- Three wrong PINs lock the account and the machine keeps the card
- Dispense exact amounts from limited stock of 100, 50, 20 and 10 notes
- Eject the card to end the session
- Out-of-cash and out-of-service handling
- An audit trail of every event
Non-functional
- Never dispense without a matching debit, and never debit without cash
- Two machines cannot overspend one account
- Exactly one outcome per event in each state
- A new note value needs no change to existing handlers
- Deterministic tests: the cash unit is injected
Out of scope
- Card reader and PIN pad hardware
- The bank network and settlement (an HLD follow-up)
- Fraud detection
A low-level design (LLD) question with three tests: spot the state machine, make change from limited notes, and keep money and cash consistent.
New to this? Read State machines and observers first.
Clarify, interface and state
Ask first:
Read more · 153 words
- Withdraw only, or deposit and transfer too?
- Which note values, and can the machine run out of one?
- What happens after three wrong PINs?
- Does a jammed cash unit still charge the customer?
- Does one account ever use two machines at once?
The interface
| Call | Accepted in | Outcome |
|---|---|---|
insertCard(id) | Idle | Card in, or rejected if unknown or locked |
enterPin(pin) | CardIn | Authenticated, try again, or locked |
withdraw(amount) | Authenticated | Notes and new balance, or a reason |
balance() | Authenticated | Current balance |
eject() | CardIn, Authenticated | Back to Idle |
Every call returns a result with ok. A rejection never throws and never changes anything.
What it remembers
state: Idle, CardIn or Authenticated.card: the account of the current session, or none.stock: how many notes of each value are left.- On each
Account: balance, PIN, failed tries and a locked flag.
The PIN counter belongs to the account so it is shared by every machine.
State transition table
Three states and five events, one outcome per cell. ✕ means rejected: nothing changes.
Read more · 88 words
| Event | Idle | CardIn | Authenticated |
|---|---|---|---|
| insertCard | → CardIn¹ | ✕ | ✕ |
| enterPin | ✕ | → Authenticated, stay², or → Idle³ | ✕ |
| withdraw | ✕ | ✕ | stay: notes out, or ✕ with a reason |
| balance | ✕ | ✕ | stay |
| eject | ✕ | → Idle | → Idle |
¹ Rejected for an unknown or locked card. ² A wrong PIN with tries left. ³ The third wrong PIN: lock and keep the card.
Is a table worth it? Here, yes: a flag like pinOk is easy to forget to clear on eject, and the next customer inherits the session. A table has no flags to forget, and a test can loop over every unlisted cell.
- Client
- Service
- Database
Text description
- Customer → Atm: enterPin('1234')
- Atm → Account: tryPin('1234')
- Account replies to Atm: ok, counter reset to 0
- Atm replies to Customer: authenticated
Build the design in stages
Step through the stages. Green marks what the latest stage added.
Atm holds the current card and the note stock and points at its current state. Each state (Idle, CardIn, Authenticated) lists the events it accepts. Withdrawals ask a chain of NoteHandlers for a note plan, debit the Account, then tell the injected CashUnit to dispense. Listeners observe every event.
- ◆ composition (owns)
- ◇ aggregation
- △ inheritance / implements (dashed)
- → association · ⇢ dependency
Cash is a chain
Each handler owns one note value.
Read more · 130 words
It takes the most notes it can, passes the remainder to the next, and if the rest cannot be paid it tries one note fewer. That backtrack matters: with one 50 and three 20s, a greedy 60 takes the 50 and strands 10, but the chain finds three 20s.
When it is over-engineering. Four fixed notes are a plain loop over an array, with the same backtracking. The chain earns its place when handlers differ in more than a number: a limit per , a handler that only runs in one region, or rules that change per machine. Offer it, then say the loop is enough.
plan(amount, stock) { // one handler: its own note value
const most = Math.min(Math.floor(amount / this.denom), stock[this.denom] ?? 0);
for (let n = most; n >= 0; n--) {
const tail = this.next.plan(amount - n * this.denom, stock);
if (tail) return n ? { [this.denom]: n, ...tail } : tail;
}
return null;
}Withdraw, step by step
Play it and open each branch: dispensed, no exact notes, insufficient funds, and a cash jam.
Read more · 73 words
Plan the notes first, since a plan changes nothing. Then debit in one locked step, then dispense. If the cash unit fails, credit the debit back.
Two machines share one account, so debit checks and subtracts inside the account's lock. Without that, both can read 50 and each pay 50. One machine serves one person at a time and needs no lock.
Money is whole dollars in an int. Never use a float.
- Client
- Service
- Worker
- Database
- External API
Text description
- Customer → Atm: withdraw(130)
- Atm → Note chain: plan(130, stock)
- Note chain replies to Atm: 100 x1, 20 x1, 10 x1
- Atm → Account: debit(130)
- Account replies to Atm: true, balance 370
- Atm → Cash unit: dispense(notes)
- Cash unit replies to Atm: ok
- Atm replies to Customer: 3 notes, balance 370
Step-by-step answer
Order: clarify, draw the states, fill in the table, design the cash path, then break it (wrong PINs, jam, two machines, low stock).
Read more · 178 words
Events and states
Five events. The states are Idle, CardIn and Authenticated. Locking is a flag on the account, not a state.
The State pattern as a table
Each state maps the events it accepts to a handler that returns the next state. A rejection is returned before anything changes.
cardIn: { enterPin: (atm, { pin }) => { const result = atm.card.tryPin(pin); if (result === 'ok') return to('authenticated', { authenticated: true }); if (result === 'locked') { atm.card = null; return to('idle', { retained: true }); } return to('cardIn', { attemptsLeft: atm.card.attemptsLeft }); }, eject: atm => { atm.card = null; return to('idle'); }, },Withdraw in four moves
Validate the amount, plan the notes, debit under the account lock, then dispense. Each move can fail without leaving a mess behind it.
Why plan first
A plan is pure. Failing there costs nothing. Failing after a debit costs an undo.
Add a deposit
One more event in Authenticated. The table gains a cell and no other state changes.
Tests
Loop over every unlisted pair. Add the greedy counterexample, the lockout, the jam, and a thread test on one account.
Implementation
The same design in JavaScript, Java or Python. Java and Python add real locks on the account.
Read more · 53 words
planreads the stock but never changes it. Notes leave the stock only after the cash unit says yes.- The test walks every unlisted (state, event) pair and checks that nothing changed.
Code it yourself. Try the ATM exercise: it checks the lockout, the exact-note planning and the order of debit and dispense.
export const NOTES = [100, 50, 20, 10]; // largest first; money is whole dollars in integers, never floats
export const MAX_PIN_TRIES = 3;
export class Account {
constructor(id, pin, balance) {
Object.assign(this, { id, pin, balance });
this.failedPins = 0; // lives on the account, so two machines share one count
this.locked = false;
}
get attemptsLeft() { return MAX_PIN_TRIES - this.failedPins; }
tryPin(pin) {
if (this.locked) return 'locked';
if (pin === this.pin) { this.failedPins = 0; return 'ok'; }
if (++this.failedPins >= MAX_PIN_TRIES) { this.locked = true; return 'locked'; }
return 'wrong';
}
// Check and subtract in one step. In Java and Python this method holds the account's lock.
debit(amount) {
if (this.balance < amount) return false;
this.balance -= amount;
return true;
}
credit(amount) { this.balance += amount; } // also the undo for a debit when the cash jams
}Failure modes and trade-offs
Read more · 178 words
| What breaks | Effect | Mitigation |
|---|---|---|
| Cash jams after the debit | Charged, no cash | Credit back; in production, hold then commit |
| Power cut mid-withdraw | Unknown outcome | Write the intent first; reconcile on boot |
| Two machines, one account | Overspend | Check and subtract under the account lock |
| PIN counter per session | Unlimited guesses | Keep it on the account |
| A note cassette runs low | Valid requests fail | Backtracking plan; alert at low stock |
| Card left behind | Next user inherits a session | Timeout, then eject and clear state |
Trade-offs
| Choice | Pros | Cons |
|---|---|---|
| Table vs flags | Every cell visible | More structure |
| Chain vs loop | Open for new rules | An object per note value |
| Plan first vs debit first | Most failures cost nothing | Stock can change in between |
| Undo vs hold and commit | Simple | A crash between the steps needs a log |
Follow-up questions
How would you add deposits?
A deposit event in Authenticated. Count the notes, credit after the machine verifies them, and keep them in a separate bin.
How would you add a daily limit?
Track spent-today on the account and check it inside debit, in the same lock as the balance check.
How would you add a 200 note?
Add one handler at the front of the chain, or one number in the list. No existing handler changes.
What if the machine loses power mid-withdraw?
Log the intent before the debit and the result after the dispense. On boot, reconcile any open entry with the bank.
How do you test the cash unit failing?
Inject a fake CashUnit that returns false, then check the balance and stock did not change.
Check yourself
Question 1 of 3Score 2 out of 3 or better to mark this page complete.