Design an ATM

Design an ATM LLD: a card session, a bank port, a cash dispenser, and a journal that stops a silent debit or a free payout.

Page content

Alice inserts a card, enters her PIN, and asks for ₹2000. The cassette has the notes.

If you debit her bank and then the dispenser jams, she is charged and has no cash. If you dispense first and the debit fails, the ATM is a charity. The invariant is one sentence:

Cash leaves the machine if and only if the bank accepted a withdraw with that ATM journal id.

This is an LLD interview: a session state machine, hardware ports, and a journal. The bank is an interface you mock. It is not a payments HLD. There is no Kafka, no multi-region ledger, and no 50k QPS.

The main design question is:

How do we run a card session, talk to a bank, and dispense cash without a double-pay or a silent debit?

We will start with one withdraw() method and watch a jam and a timeout become unrecoverable. The session machine, the bank port, and the journal appear when that method is no longer enough. The same object muscle as Design a Parking Lot — one machine, one critical sequence — not a city of ATMs.

1. Clarify the problem

“Design an ATM” can mean a full branch network or a box that talks to a switch. The questions that change the objects:

  • Withdraw only, or also balance and mini-statement?
  • One currency or many?
  • Card plus PIN, or cardless / UPI too?
  • Deposits? Cheque? Cash recycler?
  • What notes sit in the cassettes?
  • What happens after three wrong PINs?
  • Who owns the account balance — us, or the bank?

If the interviewer gives no extra constraints, I would state:

Operations          Withdraw, balance, mini-statement
Currency            INR only
Auth                Card + PIN; bank verifies the PIN
Deposit             Out of scope
Cardless / UPI      Out of scope
Notes               100, 200, 500, 2000
PIN lock            3 wrong attempts → retain card
Balance             The bank is the source of truth
Bank                Interface we mock (BankService)

A deposit path is a different hardware story (acceptor, escrow, credit). Say you are skipping it and move on.

2. Functional requirements

The system must:

  1. Read a card, collect a PIN, and open a session only after the bank authenticates.
  2. Show a menu: withdraw, balance, mini-statement.
  3. Withdraw a requested amount when both the ATM and the bank can honour it.
  4. Dispense an exact note plan, or refuse before any debit.
  5. Eject the card on success or cancel; retain it after three wrong PINs.
  6. Persist a journal row for every withdraw so a crash is recoverable.

Not in v1: deposit, transfers, bill pay, cardless withdraw, foreign currency, receipt printer as source of truth. A printer is an output. The journal is the record.

3. Non-functional requirements

This is not a throughput problem. Interviewers want a state machine and an idempotent bank call, not a cluster.

RequirementTarget
SessionOne card at a time; idle timeout ejects
WithdrawSeconds; bank round-trip dominates
JournalDurable before the bank call and before dispense
PINNever stored, never logged, wiped after auth
ClockATM clock on the journal; bank time on the ledger

The bank may be down. The dispenser may jam. Power may drop after a debit. Those are the NFRs that change the design.

Edge cases

  • Alice types the PIN wrong once, then correctly.
  • Alice types the PIN wrong three times.
  • The cassette cannot make ₹2000 even though the bank would approve.
  • The bank declines (insufficient funds) even though the cassette is full.
  • The bank call times out. We do not know if the debit landed.
  • The dispenser jams after the bank accepted j_9f3a.
  • Alice cancels at PIN, at the menu, or after typing an amount.
  • The same journal id is retried after a timeout.
  • Power fails between debit and dispense.
  • A second person cannot start while Alice’s card is in.

Insufficient ATM cash and insufficient bank funds are different errors. The first is local. The second is the bank. Do not collapse them into FAILED.

4. Scale

Say the numbers so nobody spends the interview on QPS.

Machines            This ATM, this process
Sessions            Exactly one
Withdraws / day     Hundreds, not millions
Journal rows        Years of audit, tiny
Bank                Remote; we do not own its ledger

A fleet of ATMs is many copies of this design, each with its own journal, talking to the same BankService. Do not invent a shared session store. Do not invent Kafka so two machines can “agree.” They already agree at the bank, by journal id.

If they push “what about the switch behind 10,000 ATMs,” that is a different interview. This one is the box Alice is standing at.

5. Entities / what we store

Hardware ports, a session that dies when the card leaves, and a journal that does not.

CardReader       read / eject / retain
Keypad           digits; PIN is masked
Screen           prompts and errors
Cassette         denomination, remaining count
CashDispenser    plan(amount) -> NotePlan | CANNOT
                 dispense(plan) -> OK | JAM
BankService      interface: auth, balance, withdraw, reverse
Session          card, account, pinAttempts, state
Journal          id, atmId, account, amount, plan, status
ATM              owns one session, one dispenser, one bank port

A session is “Alice is at this machine right now.” It is ephemeral. A journal is “we intended to pay ₹2000 under j_9f3a.” It survives a reboot.

Alice inserts card
  Session S1  CARD_IN
  authenticate ok
  Session S1  MENU
  she asks for 2000
  Journal j_9f3a  CREATED
  bank.withdraw(..., j_9f3a) accepted
  Journal j_9f3a  BANK_APPROVED
  dispenser OK
  Journal j_9f3a  DISPENSED
  eject
  Session gone

The card number on the journal is truncated. The PIN is not on the journal at all. The receipt, if they ask for one, is a print of the journal. It is not a second ledger.

BankService is a port. In the interview you mock it. In production a host switch sits behind that interface. Either way, the ATM does not keep account balances.

6. APIs or class methods

In LLD you show methods, not a public HTTP API. Hardware events come in; bank calls go out.

ATM
  onCardInserted()
  onPinDigit / onPinEntered(pin)
  onMenu(choice)
  onAmountEntered(amount)
  onCancel()
  onTimeout()

BankService
  authenticate(cardNumber, pin) -> AuthResult
  getBalance(account) -> Money
  getMiniStatement(account) -> List<Line>
  withdraw(account, amount, journalId) -> WithdrawResult
  reverse(journalId) -> ReverseResult

WithdrawResult is APPROVED, DECLINED, or UNKNOWN (timeout / disconnect). UNKNOWN is the dangerous one. Treat it as “maybe debited” and retry the same journalId, or reverse once you know.

A bank request, if they want to see the wire, is a payload — not a theme http fence:

Request:

POST /bank/v1/withdraw
{ "account": "a_alice", "amount": { "minor": 200000, "currency": "INR" }, "journal_id": "j_9f3a" }

Response (approved):

{ "status": "APPROVED", "journal_id": "j_9f3a", "auth_code": "A91" }

Response (already seen):

{ "status": "APPROVED", "journal_id": "j_9f3a", "auth_code": "A91", "replay": true }

The second body is the Senior point. The bank keys the debit on journal_id. A timeout retry is not a second ₹2000.

7. Start with a naive withdraw() — show the jam

The first design everyone writes:

function withdraw(card, pin, amount):
  bank.authenticate(card, pin)
  bank.withdraw(card.account, amount)   // no journal id
  dispenser.dispense(amount)
  reader.eject()

It is easy to draw and easy to break.

Alice wants ₹2000
  bank says OK, her account is -2000
  first 500-rupee note feeds
  roller jams
  tray is empty
  Alice is charged

Swap the last two lines and you get the other disaster:

dispense 2000          // cash is in the tray
bank.withdraw(...)     // timeout
retry withdraw         // she is charged twice
                       // or: never charged, ATM is a charity

A jam after debit with no journal is unrecoverable: you do not know which withdraw to reverse. A timeout after debit with no journal is unrecoverable: a retry is a second debit.

The bug is not “we forgot a try/catch.” The bug is two side effects with no shared id. Cash motion and bank money must name the same journalId, and that row must exist before either side effect finishes.

naive withdraw()                 wrong
dispense then debit              charity or double charge
debit then dispense, no journal  silent debit on jam
journal + idempotent debit       the design

We will use the last one. Reverse is how we undo a debit when the cash never left. Reconcile is what ops does when reverse also fails.

8. Session state machine

The card in the reader is a lock. Do not let a second session start. Draw the machine, then walk Alice through it.

                    insert
  IDLE ──────────────────────────► CARD_IN
    ▲                                 │
    │                          read ok│
    │                                 ▼
    │                               PIN
    │                          /     │     \
    │              wrong < 3  /      │      \ wrong = 3
    │                        /    correct    \
    │                       ▼        │        ▼
    │                     PIN       MENU     RETAIN ──► IDLE
    │                                 │
    │                    withdraw     │  cancel / timeout
    │                                 ▼
    │                         WITHDRAW_AMOUNT
    │                          /      │
    │            cannot make  /       │ amount ok
    │                        ▼        ▼
    │                      MENU    BANK_AUTH
    │                          /      │      \
    │                 declined /   approved   \ UNKNOWN
    │                         ▼       │        \
    │                       MENU      ▼         retry same journal
    │                              DISPENSE
    │                              /      \
    │                           ok/        \ jam
    │                            ▼          ▼
    │                          EJECT     reverse / RECONCILE
    │                            │          │
    └────────────────────────────┴──────────┘

Illegal transitions matter as much as the happy path.

IDLE           no PIN, no withdraw
PIN            no bank.withdraw
MENU           card is authenticated; PIN already wiped
BANK_AUTH      journal exists; cassette plan is reserved
DISPENSE       journal is BANK_APPROVED
EJECT / RETAIN always return to IDLE; session destroyed

Balance and mini-statement are MENU actions that call the bank and return to MENU. They do not touch the dispenser. They may write an inquiry row for audit. They do not write a cash journal.

Cancel and idle timeout from PIN, MENU, or WITHDRAW_AMOUNT eject the card and go to IDLE. After BANK_AUTH you do not cancel by forgetting the journal. You finish the protocol: dispense, reverse, or mark reconcile.

This is the same habit as the parking-lot ticket machine: do not hide “Alice is leaving” as a spot status. Session and journal are two machines.

Session

  IDLE → CARD_IN → PIN → MENU → WITHDRAW_AMOUNT → BANK_AUTH → DISPENSE
  any pre-auth cancel / timeout → EJECT → IDLE
  PIN × 3 → RETAIN → IDLE

Journal

  CREATED ── APPROVED ──► BANK_APPROVED ── dispense OK ──► DISPENSED
      │          │              │
      │ DECLINED │ UNKNOWN      │ jam / no cash out
      ▼          ▼              ▼
   DECLINED  BANK_PENDING    REVERSING ── reverse OK ──► REVERSED
                 │              │
                 │ retry OK     │ reverse fails / unknown
                 └──────────    ▼
                             RECONCILE

The pair that must stay together: we only call dispense from BANK_APPROVED. We only call reverse from BANK_APPROVED when cash did not leave. DISPENSED → REVERSED is illegal. That would be a refund of cash she already holds.

9. Bank port

The ATM does not own rupees in Alice’s account. It asks. Keep the interface small enough to mock in tests.

authenticate(cardNumber, pin) ->
  OK { accountId } | WRONG_PIN | CARD_BLOCKED | UNAVAILABLE

getBalance(accountId) -> Money | UNAVAILABLE

getMiniStatement(accountId) -> lines | UNAVAILABLE

withdraw(accountId, amount, journalId) ->
  APPROVED | DECLINED | UNKNOWN

reverse(journalId) ->
  REVERSED | ALREADY_REVERSED | UNKNOWN

Three rules you say out loud:

  1. withdraw is idempotent on journalId. Two calls with j_9f3a are one debit.
  2. authenticate never returns the PIN back. The ATM wipes the PIN buffer after the call.
  3. UNKNOWN is not DECLINED. Declined means do not dispense and do not reverse. Unknown means you must not dispense until a retry (same id) or a later reverse settles it.

I would not put hold and capture on the port unless they ask. A single withdraw that the bank can reverse is enough for this interview. If they offer a hold API, use it: hold with journalId, dispense, then capture — or release the hold on jam. Same invariant, extra round-trip.

        ATM                         Bank
         |                            |
         |-- withdraw(a, 2000, j_9f3a) ->
         |                            |
         |   timeout, no body         |
         |                            |
         |-- withdraw(a, 2000, j_9f3a) ->
         |<- APPROVED (replay) -------|
         |                            |
         |  now we may dispense       |

The first call may already have debited her. The second call must not. That is why the journal id is minted locally, persisted, and then sent. Do not let the bank invent the id. If the ATM crashes before hearing APPROVED, reboot looks at j_9f3a and asks again.

10. Two-phase withdraw

Requirement: Alice gets ₹2000, or she is not charged. Constraint: the dispenser and the bank cannot be one transaction. Decision: persist a journal, debit by that id, then dispense. Trade-off: a jam now has a name, and we owe her a reverse.

withdraw(amount):
  plan = dispenser.plan(amount)
  if plan is CANNOT:
    screen "ATM cannot dispense this amount"
    return to MENU          // no journal, no bank

  j = journal.create(account, amount, plan)    // CREATED, durable

  result = bank.withdraw(account, amount, j.id)

  if result == DECLINED:
    journal.DECLINED
    screen "Bank declined"
    return to MENU

  if result == UNKNOWN:
    result = retry withdraw with j.id, or stop and do not dispense
    if still UNKNOWN: journal stays BANK_PENDING; ops / boot recovery
    return   // never dispense on UNKNOWN

  journal.BANK_APPROVED
  disp = dispenser.dispense(plan)

  if disp == OK:
    journal.DISPENSED
    screen "Take cash"
    eject
    return

  // jam: cash did not leave, or only reject-bin
  rev = bank.reverse(j.id)
  if rev == REVERSED or ALREADY_REVERSED:
    journal.REVERSED
  else:
    journal.RECONCILE
  screen "Could not dispense. If charged, it will be reversed."
  eject

I pick reverse when the bank supports it. State the other option: if reverse is missing, mark RECONCILE and let a host file settle overnight. Do not pretend the ATM can un-write a ledger it does not own.

Why reverse rather than “just retry dispense”? Because a jammed path may eat notes into a reject cassette. Retrying can double-feed or pay her after a reverse already landed. Recovery is: reverse the debit, physically recount the cassettes, clear the jam. Paying her from a jammed machine is an ops decision, not a silent second dispense.

        Alice                      ATM                        Bank
          |                         |                           |
          |-- amount 2000 --------->|                           |
          |                         |  plan = 1 x 2000          |
          |                         |  journal j_9f3a CREATED   |
          |                         |--- withdraw j_9f3a ------>|
          |                         |<-- APPROVED --------------|
          |                         |  j_9f3a BANK_APPROVED     |
          |                         |  dispense JAM             |
          |                         |--- reverse j_9f3a ------->|
          |                         |<-- REVERSED --------------|
          |                         |  j_9f3a REVERSED          |
          |<-- eject, sorry --------|                           |

Power loss after BANK_APPROVED and before dispense is the same jam path on boot: do not dispense blindly, reverse (or reconcile). Power loss after DISPENSED is done. Do not reverse cash she already took.

11. Cash dispenser

The dispenser is not dispense(2000) as a blob. It is a plan, then a feed.

Cassettes
  C1   2000   x 40
  C2    500   x 80
  C3    200   x 60
  C4    100   x 100

Greedy: largest denomination first, only if the remainder is still makeable. For Alice’s ₹2000 with a full C1, the plan is one ₹2000 note.

plan(2000)  ->  [ 2000 x 1 ]
plan(1700)  ->  [ 500 x 3, 200 x 1 ]
plan(50)    ->  CANNOT
plan(2000) when C1 empty, C2 has 3  ->  CANNOT   // 1500 left, not 2000

If greedy cannot make the amount, fail before the bank call. Do not debit her and then discover you only have ₹500 notes for a ₹200 request. Do not push ₹500 into the tray and hope she “owes” the machine.

Partial cash in the tray without a record is the other invariant break. The feed loop must be all-or-nothing from Alice’s point of view:

dispense(plan):
  for each note in plan:
    feed one
    if sensor JAM:
      pull remaining notes to reject bin if the hardware can
      return JAM     // journal still BANK_APPROVED
  return OK

On JAM, assume Alice got zero usable notes unless a tray sensor says otherwise. If a sensor says two notes cleared the gate, the journal must record planned vs exited. Reverse the full debit only when exited == 0. If two notes exited, you are in RECONCILE with a counted shortage — do not invent a partial reverse unless the bank port has reverse(journalId, amount). Default interview answer: full reverse when nothing reached the tray; reconcile when sensors disagree.

Reservation: decrement cassette counts on OK, not on plan. plan only answers “could we.” One session means no second withdraw races the counts. Still persist the plan on the journal so reboot knows what you meant to feed.

cannot make amount     local, no bank
bank declined          bank, cassette unchanged
jam after approve      bank reverse / reconcile
empty cassette         same as cannot make

12. PIN attempts and retain

The bank knows whether the PIN is right. The ATM knows how many times this session failed. Some issuers also track failures across ATMs. You still retain locally after three wrong presentations at this reader.

onPinEntered(pin):
  result = bank.authenticate(session.card, pin)
  wipe pin buffer

  if result == OK:
    session.account = result.account
    session.pinAttempts = 0
    goto MENU

  if result == UNAVAILABLE:
    screen "Bank unavailable"
    eject
    return

  session.pinAttempts += 1
  if session.pinAttempts >= 3 or result == CARD_BLOCKED:
    journal / retain-log
    reader.retain()          // card into capture bin
    session.RETAIN → IDLE
    return

  screen "Wrong PIN, try again"
  stay on PIN

Wrong PIN is not a withdraw. Do not create a cash journal. A retain log (card last-4, time, reason) is enough for the branch to return the card.

  PIN attempt 1  WRONG     stay
  PIN attempt 2  WRONG     stay
  PIN attempt 3  WRONG     RETAIN → IDLE

Cancel on the first wrong PIN ejects. She is not punished for walking away. Three failures at this machine capture the card so a thief cannot keep guessing at the same reader.

Idle timeout on PIN (say 30 seconds with no key) ejects. Do not retain on timeout. Retain is for failed authentication, not for hesitation.

13. Class sketch

Ideas, not a framework dump. One ATM instance. Hardware and bank are ports.

Money          minor, currency
NotePlan       list of (denomination, count)
Card           number (in memory), last4 (journal)

Cassette       denomination, count
  canCover(need) / take(count) on successful feed

CashDispenser
  plan(amount) -> NotePlan | CANNOT
  dispense(plan) -> OK | JAM

CardReader     read() -> Card | FAIL
               eject()
               retain()

BankService    authenticate / getBalance / getMiniStatement
               withdraw(account, amount, journalId)
               reverse(journalId)

Journal
  id, atmId, accountId, amount, plan, status, createdAt
  CREATED | BANK_PENDING | BANK_APPROVED
  DISPENSED | DECLINED | REVERSED | RECONCILE

JournalRepository
  create(...) -> Journal          // durable
  get(id) -> Journal
  save(journal)
  findOpenApproved()              // boot recovery

Session
  state, card, accountId?, pinAttempts, journalId?

ATM
  onCardInserted / onPinEntered / onMenu
  onAmountEntered / onCancel / onTimeout
  recoverOnBoot()

ATM.onAmountEntered in one breath:

onAmountEntered(amount):
  if session.state != WITHDRAW_AMOUNT: ignore
  plan = dispenser.plan(amount)
  if plan is CANNOT:
    screen cannot-make
    session.state = MENU
    return
  j = journals.create(session.account, amount, plan)
  session.journalId = j.id
  session.state = BANK_AUTH
  outcome = authorizeAndDispense(j, plan)
  // authorizeAndDispense is section 10

Handlers do not decrement cassettes. They do not call bank.withdraw without a persisted journal. Tests fake BankService and CashDispenser; they do not fake the journal state transitions if they can help it.

recoverOnBoot walks BANK_APPROVED and BANK_PENDING rows and finishes reverse or retry. A machine that starts in IDLE with an approved journal still in the table is a bug unless recovery runs first.

14. Concurrency

One ATM, one session. Say it before they drag you to Redis.

Card in reader     =  the lock
Session != IDLE    =  ignore a second insert (hardware already forbids it)
Journal ids        =  unique per attempt, never reused across Alice and Bob

Two people cannot withdraw on one reader. Two threads can still exist: a timeout timer, a bank callback, a boot recovery. Those must not run dispense twice for j_9f3a.

synchronized (atmLock):     // or a single event loop
  if journal.status == DISPENSED: return
  if journal.status == REVERSED:  return
  // only one of: dispense, reverse, retry withdraw

Idempotency is the concurrency story, not a thread pool. The bank may see the same withdraw(j_9f3a) from a retry and from recovery. That is why the id is on the port.

A second ATM downtown is a second process, a second journal namespace (atmId + id), the same bank. Bob’s ₹2000 on that machine cannot spend Alice’s j_9f3a. Do not “shard sessions.” There is nothing to shard.

If they ask about cassette counts under concurrency: there is no concurrent withdraw on this box. Counts change in the same critical section as DISPENSED.

15. Tests that matter

Skip getters. Test the invariant.

  1. Wrong PIN then retain. Two wrong PINs stay on PIN and eject still works. Third wrong → retain(), session IDLE, no withdraw call.
  2. Jam after debit. Mock bank APPROVED, dispenser JAM. Assert reverse(j_9f3a) ran, journal REVERSED (or RECONCILE if reverse returns UNKNOWN), cassette counts unchanged, Alice not in DISPENSED.
  3. Cannot make amount. Cassettes are three ₹500 notes. Alice asks for ₹2000. No journal. No bank call. Stay on MENU.
  4. Exact cassette empty. plan(2000) was possible, then you empty C1 in a fixture and ask again → CANNOT. Bank funds are irrelevant.
  5. Idempotent journal retry. First withdraw returns UNKNOWN. Retry with the same id returns APPROVED / replay. Bank mock was charged once. Then dispense OKDISPENSED.
  6. Bank declined. Cassette full. DECLINED. No dispense. Journal DECLINED.
  7. Cancel at PIN. Eject, not retain. pinAttempts discarded.
  8. Boot after approved, no dispense. Journal left BANK_APPROVED. recoverOnBoot reverses; does not feed notes.

Test (2) and (5) are the offer. If they only have time for two, run jam-after-debit and the timeout retry.

16. Extensions

Stay on objects. Do not add a message bus.

Deposit. Acceptor, escrow, then bank.credit(journalId). Inverse jam: cash is in the box and the credit failed — same journal, opposite sign. Out of scope until they insist.

UPI / cardless. A phone app mints a one-time code. The session starts at MENU without CardReader.read. The journal still keys the debit. The PIN machine above does not change.

Receipt printer. Renders the journal. Paper jams are not a ledger.

Multiple currencies / other notes. Another cassette type. plan is the same greedy function on a different set.

Hot card list. authenticate returns CARD_BLOCKED → retain. Still not a payments network design.

Parking-lot energy applies: Design a Parking Lot is also one machine and a state pair. An ATM session is not a stall. Do not reuse park() language here.

17. Final architecture / object diagram

One box. One card. One journal per attempt.

  CardReader     Keypad      Screen
       |            |           |
       |     hardware events    |
       v            v           v
  +----------------------------------+
  |               ATM                |
  |  session state machine           |
  |  onCard / onPin / onAmount       |
  |  recoverOnBoot                   |
  +--------+-------------+-----------+
           |             |
           v             v
   +---------------+  +----------------+
   | JournalRepo   |  | CashDispenser  |
   | create/save   |  | plan / dispense|
   | findApproved  |  | Cassette[]     |
   +-------+-------+  +--------+-------+
           |                   |
           v                   v
     journal rows         note plan / jam
     j_9f3a DISPENSED     C1 2000 x 39
           |
           v
   +---------------+
   | BankService   |   << interface, mocked >>
   | authenticate  |
   | withdraw(id)  |
   | reverse(id)   |
   +---------------+
insert → PIN → MENU
withdraw: plan → journal CREATED → bank.withdraw(id) → dispense → DISPENSED → eject
jam:     reverse(id) or RECONCILE → eject
PIN x3:  retain → IDLE

Kafka, Redis, and a region pair are not in this picture. If those words appear, you left LLD.

Source of truth    Journal + bank ledger (bank wins on money)
Derived            Screen copy, paper receipt
Ephemeral          Session, PIN buffer, in-flight plan
Audit              DISPENSED / REVERSED / RECONCILE rows
Hardware truth     Cassette counts after a successful feed

On conflict — journal says DISPENSED, cassettes are short — ops counts cash. The ATM does not “fix” the bank.

18. Interview-ready summary

How to walk through in 10–15 minutes

0–2 min. Alice, ₹2000, jam vs charity. LLD, bank is a mock.
2–4 min. Withdraw, balance, mini-statement. Card+PIN. No deposit.
4–6 min. Entities: ports, Session, Journal. Journal is the lock.
6–9 min. Naive withdraw(), then the session machine.
9–12 min. Idempotent withdraw(journalId), reverse on jam.
12–15 min. Greedy plan, PIN × 3 retain, one session, jam + retry tests.

Key decisions to remember

  1. This is objects and a session, not a payments platform.
  2. Cash leaves iff the bank accepted that journal id.
  3. Persist the journal before the bank call.
  4. withdraw and reverse are idempotent on that id.
  5. UNKNOWN is not DECLINED; do not dispense on unknown.
  6. Plan notes first; refuse locally if greedy cannot make the amount.
  7. Jam with nothing in the tray → reverse; sensor disagreement → reconcile.
  8. Three wrong PINs retain; timeout and cancel eject.
  9. PIN is wiped and never journaled.
  10. One ATM, one session. A fleet is many copies, same bank port.

Likely interviewer follow-up questions

  • Debit first or dispense first — why neither alone works?
  • Bank timeout: do you retry, reverse, or wait?
  • What if reverse also times out?
  • Can greedy fail when a mix of smaller notes would work? (Say you pick greedy; a DP planner is an extension.)
  • What is in the journal if two notes exited during a jam?
  • Why not store the PIN “encrypted” on the session?
  • How does boot recovery avoid a second dispense?
  • How is this different from a vending machine? (Session yes; bank journal is the ATM-specific invariant.)
  • What would deposit change?

Senior-level points that differentiate the answer

  • Name the jam and the charity before drawing classes.
  • Put journalId on the bank port in the first API sketch, not as an afterthought.
  • Separate Session and Journal; do not stash BANK_APPROVED only in a boolean on Alice’s card.
  • Treat UNKNOWN as a first-class result.
  • Refuse a partial tray without a planned-vs-exited record.
  • Recovery on boot is part of the design, not “we’ll add logging.”
  • Keep scale at one machine. The people who add Kafka here fail the interview.

A 1–2 minute verbal answer

An ATM is a session state machine plus a journal. Alice inserts a card, we read it, she enters a PIN, and we ask a mocked BankService to authenticate. The PIN is wiped and never stored. Withdraw is two-phase: plan the notes, persist a journal id, debit that id — the bank call is idempotent — then dispense. Cash leaves the tray only after that id was approved. If the dispenser jams and nothing reached the tray, we reverse; if reverse fails or sensors disagree, we mark reconcile. If the cassette cannot make the amount, we never call the bank. Three wrong PINs retain the card. Cancel and timeout eject. One ATM, one session. I would test retain, jam-after-debit, a cassette that cannot make ₹2000, and a timeout retry that does not debit twice.

For HLD framing around this, see the System Design Interview Complete Guide and the questions hub. Same LLD muscle, different invariant: Design a Parking Lot. Local cash and no bank: Design a Vending Machine.