BackendMedium35 min

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.

Say it in 30 seconds
  • 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.

0 of 0 sections ticked
30-second answer

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

CallAccepted inOutcome
insertCard(id)IdleCard in, or rejected if unknown or locked
enterPin(pin)CardInAuthenticated, try again, or locked
withdraw(amount)AuthenticatedNotes and new balance, or a reason
balance()AuthenticatedCurrent balance
eject()CardIn, AuthenticatedBack 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
EventIdleCardInAuthenticated
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.

Press Play to watch it run · 4 steps

The failed-attempt counter lives on the account, not on the machine, so trying three PINs on each of ten machines does not work. The third wrong PIN locks the account and the machine keeps the card.

Entering a PINThe failed-attempt counter lives on the account, not on the machine, so trying three PINs on each of ten machines does not work. The third wrong PIN locks the account and the machine keeps the card.state CARD_INenterPin('1234')tryPin('1234')alt [right PIN]ok, counter reset to 0authenticatedCARD_IN → AUTHENTICATED
  • Client
  • Service
  • Database
Text description
  1. Customer → Atm: enterPin('1234')
  2. Atm → Account: tryPin('1234')
  3. Account replies to Atm: ok, counter reset to 0
  4. Atm replies to Customer: authenticated

Build the design in stages

Step through the stages. Green marks what the latest stage added.

Stage 1 of 5

The machine and the account

An ATM works on one card at a time, and the card points at an account that owns the balance and the PIN counter.

ATM class diagramAtm 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.Atmstate: StateNamecard: Account?stock: { note: count }insertCard(id)enterPin(pin)withdraw(amount)balance()eject()Accountpin, balance: intfailedPins, lockedtryPin(pin)debit(amount): boolcredit(amount)card

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.

Press Play to watch it run · 8 steps

The ATM first asks the chain for a note plan, because planning changes nothing. Only with a plan does it debit the account in one locked step, and only then does it ask the cash unit to dispense. If the cash jams, the debit is reversed. Pick a branch to see each outcome.

Withdraw 130 from an authenticated sessionThe ATM first asks the chain for a note plan, because planning changes nothing. Only with a plan does it debit the account in one locked step, and only then does it ask the cash unit to dispense. If the cash jams, the debit is reversed. Pick a branch to see each outcome.state AUTHENTICATED, balance 500withdraw(130)plan(130, stock)alt [dispensed]100 x1, 20 x1, 10 x1debit(130)true, balance 370dispense(notes)ok3 notes, balance 370stock reduced, still AUTHENTICATED
  • Client
  • Service
  • Worker
  • Database
  • External API
Text description
  1. Customer → Atm: withdraw(130)
  2. Atm → Note chain: plan(130, stock)
  3. Note chain replies to Atm: 100 x1, 20 x1, 10 x1
  4. Atm → Account: debit(130)
  5. Account replies to Atm: true, balance 370
  6. Atm → Cash unit: dispense(notes)
  7. Cash unit replies to Atm: ok
  8. 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
  1. Events and states

    Five events. The states are Idle, CardIn and Authenticated. Locking is a flag on the account, not a state.

  2. 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'); },
    },
  3. 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.

  4. Why plan first

    A plan is pure. Failing there costs nothing. Failing after a debit costs an undo.

  5. Add a deposit

    One more event in Authenticated. The table gains a cell and no other state changes.

  6. 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
  • plan reads 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 breaksEffectMitigation
Cash jams after the debitCharged, no cashCredit back; in production, hold then commit
Power cut mid-withdrawUnknown outcomeWrite the intent first; reconcile on boot
Two machines, one accountOverspendCheck and subtract under the account lock
PIN counter per sessionUnlimited guessesKeep it on the account
A note cassette runs lowValid requests failBacktracking plan; alert at low stock
Card left behindNext user inherits a sessionTimeout, then eject and clear state

Trade-offs

ChoiceProsCons
Table vs flagsEvery cell visibleMore structure
Chain vs loopOpen for new rulesAn object per note value
Plan first vs debit firstMost failures cost nothingStock can change in between
Undo vs hold and commitSimpleA 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 3

Score 2 out of 3 or better to mark this page complete.

1. The ATM has one 50 and three 20s, and the customer asks for 60. What does a greedy dispenser do?