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:
- Read a card, collect a PIN, and open a session only after the bank authenticates.
- Show a menu: withdraw, balance, mini-statement.
- Withdraw a requested amount when both the ATM and the bank can honour it.
- Dispense an exact note plan, or refuse before any debit.
- Eject the card on success or cancel; retain it after three wrong PINs.
- 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.
| Requirement | Target |
|---|---|
| Session | One card at a time; idle timeout ejects |
| Withdraw | Seconds; bank round-trip dominates |
| Journal | Durable before the bank call and before dispense |
| PIN | Never stored, never logged, wiped after auth |
| Clock | ATM 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:
withdrawis idempotent onjournalId. Two calls withj_9f3aare one debit.authenticatenever returns the PIN back. The ATM wipes the PIN buffer after the call.UNKNOWNis notDECLINED. 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.
- Wrong PIN then retain. Two wrong PINs stay on
PINand eject still works. Third wrong →retain(), sessionIDLE, nowithdrawcall. - Jam after debit. Mock bank
APPROVED, dispenserJAM. Assertreverse(j_9f3a)ran, journalREVERSED(orRECONCILEif reverse returnsUNKNOWN), cassette counts unchanged, Alice not inDISPENSED. - Cannot make amount. Cassettes are three ₹500 notes. Alice asks for ₹2000. No journal. No bank call. Stay on MENU.
- Exact cassette empty.
plan(2000)was possible, then you emptyC1in a fixture and ask again →CANNOT. Bank funds are irrelevant. - Idempotent journal retry. First
withdrawreturnsUNKNOWN. Retry with the same id returnsAPPROVED/replay. Bank mock was charged once. Then dispenseOK→DISPENSED. - Bank declined. Cassette full.
DECLINED. No dispense. JournalDECLINED. - Cancel at PIN. Eject, not retain.
pinAttemptsdiscarded. - Boot after approved, no dispense. Journal left
BANK_APPROVED.recoverOnBootreverses; 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
- This is objects and a session, not a payments platform.
- Cash leaves iff the bank accepted that journal id.
- Persist the journal before the bank call.
withdrawandreverseare idempotent on that id.UNKNOWNis notDECLINED; do not dispense on unknown.- Plan notes first; refuse locally if greedy cannot make the amount.
- Jam with nothing in the tray → reverse; sensor disagreement → reconcile.
- Three wrong PINs retain; timeout and cancel eject.
- PIN is wiped and never journaled.
- 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
journalIdon the bank port in the first API sketch, not as an afterthought. - Separate Session and Journal; do not stash
BANK_APPROVEDonly in a boolean on Alice’s card. - Treat
UNKNOWNas 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
BankServiceto 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.
