ATM
Problem statement
Design an ATM that authenticates a card + PIN, shows balance, withdraws cash (with denomination dispensing), deposits, transfers, prints receipts, and talks to a bank service — handling card retention, daily limits and hardware failures.
Requirements
Functional
Insert card → PIN (3 attempts) → menu: balance, withdraw, deposit, transfer, PIN change.
Withdraw with denomination selection from available cassettes; daily/per-transaction limits.
Deposit cash/cheque; transfer between accounts.
Receipt; eject card; retain card after failed attempts.
Non-functional
Transactions atomic between ATM cash dispense and bank ledger.
Secure PIN handling (never stored in plaintext; encrypted to bank).
State machine prevents invalid sequences.
Hardware abstraction for testability.
API
void insertCard(Card)
AuthResult enterPin(String pin)
Money checkBalance()
DispenseResult withdraw(Money amount)
void deposit(Money amount)
void transfer(String toAccount, Money amount)
void ejectCard()
Data model
ATM(id, location,State,CardReader,Keypad,Screen,CashDispenser,DepositSlot,Printer,BankServiceclient, currentSession).State→IdleState,CardInsertedState,AuthenticatedState,TransactionState,OutOfServiceState.Card(number, expiry, bankId),Account(id, type, balance),Session(card, account, attempts, startedAt).Transaction(abstract) →Withdrawal,Deposit,Transfer,BalanceInquirywithexecute();TransactionStatus.CashDispenser(Map<Denomination, count>)withdispense(amount)using a chain-of-responsibility of dispensers (2000 → 500 → 100).BankService(interface):authenticate,getBalance,debit,credit,transfer(remote calls with idempotency keys).
Design
- State pattern:
Idle → CardInserted → Authenticated → (Transaction) → Authenticated | Idle;OutOfServiceon hardware/network faults. Each state exposes only its valid operations. - Withdrawal flow: validate limits → ask bank to reserve/debit (idempotency key = ATM id + session + seq) →
CashDispenser.dispense→ on hardware failure, reverse the debit → print receipt → return to Authenticated. - Denomination dispensing: chain of responsibility; each handler dispenses as many of its note as possible (bounded by cassette count) and passes the remainder; if remainder ≠ 0 at the end, refuse before touching the bank.
- Security: PIN entered via keypad object → encrypted PIN block sent to the bank; attempts counted in the session; 3 failures →
CardReader.retain(). - Hardware abstraction: interfaces for reader/dispenser/printer so the state machine is unit-testable with fakes.
- Session timeout: inactivity timer ejects the card.
Class diagram
Deep dives
Debit before dispense, reverse on failure: the bank must never show cash as withdrawn that the customer didn't get; idempotent reversal covers network failure between the two steps.
Chain of responsibility for notes makes adding a ₹200 cassette a one-line change.
Why the ATM never holds account state: balances come from the bank each time; the ATM caches nothing sensitive.
Receipts printed after commit; printer failure doesn't roll back the transaction.
Bottlenecks & mitigations
Bank latency → show 'processing'; timeouts with reversal logic.
Cassette exhaustion → mark denominations empty; alert operator; exact-change limits.
Failure modes
- Network timeout after debit request → unknown outcome; query by idempotency key before dispensing; if unknown, do not dispense and reverse when reachable.
- Dispenser jam after partial dispense → record partial amount, reverse the difference, flag for reconciliation.
- Card left in reader → retain after timeout; log.
- Power loss mid-transaction → journal every step to local storage; reconcile on boot.
Code skeleton
class NoteDispenser:
def __init__(self, denom, count, nxt=None): self.d, self.n, self.nxt = denom, count, nxt
def plan(self, amount, out):
use = min(amount // self.d, self.n); amount -= use * self.d
if use: out[self.d] = use
if amount == 0: return out
if self.nxt: return self.nxt.plan(amount, out)
return None
def commit(self, plan):
if self.d in plan: self.n -= plan[self.d]
if self.nxt: self.nxt.commit(plan)
class BankService: # fake for tests
def __init__(self, accounts): self.acc, self.seen = accounts, {}
def debit(self, acc, amt, key):
if key in self.seen: return self.seen[key]
if self.acc[acc] < amt: raise Exception("insufficient funds")
self.acc[acc] -= amt; self.seen[key] = True; return True
def reverse(self, acc, amt, key):
if self.seen.pop(key, None): self.acc[acc] += amt
class ATM:
DAILY_LIMIT = 40000
def __init__(self, bank, dispenser):
self.bank, self.disp, self.state, self.session, self.seq = bank, dispenser, "IDLE", None, 0
def insert_card(self, card):
assert self.state == "IDLE"; self.session = {"card": card, "attempts": 0, "withdrawn": 0}; self.state = "CARD"
def enter_pin(self, pin):
assert self.state == "CARD"
if self.session["card"]["pin"] != pin:
self.session["attempts"] += 1
if self.session["attempts"] == 3: self.state = "IDLE"; return "CARD_RETAINED"
return "WRONG_PIN"
self.state = "AUTH"; return "OK"
def withdraw(self, amount):
assert self.state == "AUTH"
if self.session["withdrawn"] + amount > self.DAILY_LIMIT: raise Exception("limit")
plan = self.disp.plan(amount, {})
if plan is None: raise Exception("cannot dispense this amount")
self.seq += 1; key = f"{id(self)}-{self.seq}"
self.bank.debit(self.session["card"]["acc"], amount, key)
try: self.disp.commit(plan)
except Exception: self.bank.reverse(self.session["card"]["acc"], amount, key); raise
self.session["withdrawn"] += amount; return plan
def eject(self): self.state, self.session = "IDLE", None
Follow-up questions interviewers ask
Add cardless withdrawal via OTP.
Add cheque deposit with image capture.
How would the bank side ensure exactly-once across thousands of ATMs?