- Model the session with the State pattern: IDLE → CARD_INSERTED → AUTHENTICATED → back to IDLE, plus OUT_OF_SERVICE. Three wrong PINs blocks the card.
- Model the cash dispenser as a Chain of Responsibility: ₹500 → ₹200 → ₹100 cassettes, each handling what it can and passing the rest on.
- The textbook greedy chain fails with limited notes (₹600 from one ₹500 and three ₹200). Let each handler try fewer notes when the rest of the chain fails.
- Money safety order: plan notes → debit with a unique transaction id → dispense → reverse on hardware failure. Never debit for an amount you already know you cannot dispense.
- Full Java code with a demo: wrong PIN, greedy-defeating withdrawal, invalid amount, dispenser jam with reversal, low cash, and card lockout.
The ATM question is a two-pattern problem. Half of it is about a session that moves through well-defined states, where the same button means different things at different times. The other half is about a piece of hardware that has to turn an amount into physical notes from a limited supply. Interviewers like it because both halves have a textbook answer that breaks under one good follow-up question.
The deeper reason it is asked, especially at fintech companies in India, is money safety. What happens when the account is debited and the cash does not come out? A candidate who answers that crisply, with transaction ids and reversals, is showing exactly the instinct payment teams hire for. For broader LLD preparation, the LLD interview guide covers the format.
ATM Requirements
Functional
- Insert card; the ATM rejects expired or blocked cards.
- Enter PIN; verified by the bank. Three consecutive wrong attempts block the card.
- After authentication: withdraw cash, check balance, (optionally) change PIN; multiple transactions per session until the card is ejected.
- Withdrawals must be multiples of ₹100, within a per-transaction limit, within the account's daily limit and balance.
- The ATM dispenses from ₹500, ₹200 and ₹100 cassettes, the denominations Indian ATMs typically stock today.
- If cash cannot be dispensed after a debit, the debit is reversed.
- Operators can take the ATM out of service and refill cassettes.
Non-functional
- Correctness of money above all: no double debits, no debits without cash.
- Security: the ATM never stores the clear PIN; PIN verification happens at the issuer.
- Auditability: every transaction has an id and a log line, which is how disputes are resolved.
ATM Hardware and Software Components
Naming the real components helps you draw the right boundaries:
| Component | Role | In the design |
|---|---|---|
| Card reader | Reads chip or magstripe, can retain the card | Card passed to insertCard; retain on lockout |
| Encrypting PIN pad | Encrypts the PIN into a PIN block | PIN passed straight to the bank service |
| Cash dispenser | Cassettes per denomination, counts notes, detects jams | CashDispenser with a chain of Cassette handlers |
| Screen, keypad, receipt printer | User interaction | Out of scope; the facade methods are what the UI calls |
| Network link to switch / issuer bank | Authorisation, debit, reversal | BankService interface |
The BankService boundary matters. The ATM does not know balances or PINs; it asks. That keeps the ATM's LLD focused on session handling and dispensing, and it is the same separation that real ATM networks have between the terminal, the switch and the issuer.
Card and PIN Handling with the State Pattern
| State \ Action | insertCard | enterPin | withdraw / balance | ejectCard |
|---|---|---|---|---|
| IDLE | Validate card → CARD_INSERTED | Reject | Reject | Reject |
| CARD_INSERTED | Reject | Correct → AUTHENTICATED; wrong → stay (3rd wrong: block, retain → IDLE) | Reject: enter PIN | → IDLE |
| AUTHENTICATED | Reject | Reject | Execute, stay | → IDLE |
| OUT_OF_SERVICE | Reject | Reject | Reject | Reject |
The State pattern is a good fit for the same reason as in the vending machine: each action's meaning depends on the session state, and illegal combinations (withdraw before PIN) should be impossible rather than guarded by flags. The wrong-PIN counter lives on the ATM session (reset on eject), while the block is recorded at the bank, because a fraudster can move to another ATM; a per-terminal counter alone is not security.
Cash Dispenser with Chain of Responsibility
The dispenser has one cassette per denomination. A request for ₹2,800 should come out as five ₹500, one ₹200 and one ₹100. The classic structure is a chain: the ₹500 handler takes what it can, passes ₹300 to the ₹200 handler, which takes one note and passes ₹100 to the ₹100 handler.
Why a chain rather than a loop over denominations? Each handler encapsulates its own cassette (count, jam sensor, maybe its own rules such as "keep a reserve of ₹100 notes"), and adding a denomination is linking a new handler, not editing a loop. That is Chain of Responsibility: a request travels along a list of handlers, each handling part or all of it. The design patterns guide shows the same pattern in middleware and logging pipelines.
Where the textbook chain breaks
The textbook handler is greedy: take min(available, amount / value) notes, pass the rest. Now load the ATM with ten ₹500 notes, three ₹200 notes and zero ₹100 notes, and ask for ₹600:
- ₹500 handler takes one note; ₹100 remains.
- ₹200 handler cannot use a ₹200 for ₹100; passes ₹100.
- ₹100 handler has no notes. Failure, although three ₹200 notes would have worked.
Cassettes running out of one denomination is routine on a busy day (₹100s go first), so this is not a contrived case. The same issue appears in vending-machine change; see the vending machine LLD for the DP version.
The fix: handlers that back off
Each handler tries k notes from its maximum down to zero, and for each k asks the rest of the chain to handle the remainder. The first k that works wins. The structure is still a chain (each handler only knows its own cassette and the next handler), but it now explores alternatives. Trying the largest k first keeps the familiar "big notes first" behaviour whenever it works.
class Cassette:
def __init__(self, value, count, nxt=None):
self.value, self.count, self.next = value, count, nxt
def plan(self, amount):
"""Return {denomination: notes} for amount, or None if impossible."""
for k in range(min(self.count, amount // self.value), -1, -1):
rest = amount - k * self.value
tail = {} if rest == 0 else (self.next.plan(rest) if self.next else None)
if tail is not None:
if k:
tail[self.value] = k
return tail
return None
chain = Cassette(500, 10, Cassette(200, 3, Cassette(100, 0)))
print(chain.plan(600)) # {200: 3} (greedy would fail)
print(chain.plan(2500)) # {500: 5}
print(chain.plan(5700)) # None: only 5600 in the machine
Complexity. With three denominations and a per-transaction cap of ₹10,000, the ₹500 handler tries at most 21 values and the ₹200 handler at most 51 for each, so about a thousand calls in the worst case. That is nothing. For many denominations, switch to the bounded coin-change DP.
k, or a reserve per cassette, both local changes inside a handler.Money Safety: Plan, Debit, Dispense, Reverse
This is the section that separates good answers from great ones. A withdrawal touches two systems that can fail independently: the bank ledger and the dispenser hardware. The order of operations decides which failures are recoverable.
- Validate locally. Multiple of ₹100, within the per-transaction limit, session authenticated.
- Plan the notes. If the dispenser cannot make the amount, stop here. Nothing has happened to the account.
- Debit with a transaction id. The ATM generates a unique id and sends it with the debit. The bank checks balance and daily limit atomically and records the id. If the network times out and the ATM retries, the same id prevents a double debit (idempotency).
- Dispense. Hardware counts and presents notes. Cassette counts change only on success.
- On hardware failure, reverse. Send a reversal referencing the transaction id. If the ATM itself loses power mid-dispense, the switch's reconciliation process later matches the ATM's journal against the debit and reverses it.
Idempotency and reversals are general payment-system ideas; the UPI payment system design shows them at scale, and ACID transactions explained covers why the bank's "check balance and debit" must be one atomic step.
ATM Class Design
Full Java Code for the ATM
Java 17+, single file, run with java AtmDemo.java. The bank is simulated in memory; in production it is a network client.
import java.time.YearMonth;
import java.util.*;
public class AtmDemo {
record Card(String number, YearMonth expiry) { }
static final class AtmException extends RuntimeException {
AtmException(String message) { super(message); }
}
static final class DispenseException extends RuntimeException {
DispenseException(String message) { super(message); }
}
// ===================== Bank side =====================
interface BankService {
boolean isBlocked(String card);
void block(String card);
boolean verifyPin(String card, String pin); // real ATMs send an encrypted PIN block
long balance(String card);
void debit(String card, long amount, String txnId); // idempotent per txnId
void reverse(String txnId);
}
static final class InMemoryBank implements BankService {
private static final class Account {
final String pin;
long balance;
long withdrawnToday;
final long dailyLimit;
boolean blocked;
Account(String pin, long balance, long dailyLimit) {
this.pin = pin;
this.balance = balance;
this.dailyLimit = dailyLimit;
}
}
private record Debit(String card, long amount) { }
private final Map<String, Account> accounts = new HashMap<>();
private final Map<String, Debit> debits = new HashMap<>(); // txnId -> debit
void open(String card, String pin, long balance, long dailyLimit) {
accounts.put(card, new Account(pin, balance, dailyLimit));
}
private Account acc(String card) {
Account a = accounts.get(card);
if (a == null) throw new AtmException("Unknown card");
return a;
}
public synchronized boolean isBlocked(String card) { return acc(card).blocked; }
public synchronized void block(String card) { acc(card).blocked = true; }
public synchronized boolean verifyPin(String card, String pin) { return acc(card).pin.equals(pin); }
public synchronized long balance(String card) { return acc(card).balance; }
public synchronized void debit(String card, long amount, String txnId) {
if (debits.containsKey(txnId)) return; // retry of the same request
Account a = acc(card);
if (a.blocked) throw new AtmException("Card is blocked");
if (amount > a.balance) throw new AtmException("Insufficient balance");
if (a.withdrawnToday + amount > a.dailyLimit) throw new AtmException("Daily withdrawal limit exceeded");
a.balance -= amount;
a.withdrawnToday += amount;
debits.put(txnId, new Debit(card, amount));
}
public synchronized void reverse(String txnId) {
Debit d = debits.remove(txnId);
if (d == null) return; // nothing to reverse, or already reversed
Account a = acc(d.card());
a.balance += d.amount();
a.withdrawnToday -= d.amount();
}
}
// ===================== Cash dispenser (Chain of Responsibility) =====================
static final class Cassette {
final int denomination;
private int count;
private Cassette next;
Cassette(int denomination, int count) {
this.denomination = denomination;
this.count = count;
}
Cassette linkTo(Cassette next) {
this.next = next;
return next;
}
/** Notes for amount from this cassette and the rest of the chain; prefers larger notes, backs off if needed. */
Optional<Map<Integer, Integer>> plan(int amount) {
for (int k = Math.min(count, amount / denomination); k >= 0; k--) {
int rest = amount - k * denomination;
Optional<Map<Integer, Integer>> tail;
if (rest == 0) tail = Optional.of(new TreeMap<Integer, Integer>(Comparator.reverseOrder()));
else tail = (next == null) ? Optional.empty() : next.plan(rest);
if (tail.isPresent()) {
if (k > 0) tail.get().put(denomination, k);
return tail;
}
}
return Optional.empty();
}
void dispense(Map<Integer, Integer> plan) {
count -= plan.getOrDefault(denomination, 0);
if (next != null) next.dispense(plan);
}
void refill(int denom, int notes) {
if (denom == denomination) count += notes;
else if (next != null) next.refill(denom, notes);
}
int total() { return denomination * count + (next == null ? 0 : next.total()); }
}
static final class CashDispenser {
private final Cassette head;
private boolean jamNext; // simulates a hardware fault for the demo
CashDispenser(Cassette head) { this.head = head; }
Optional<Map<Integer, Integer>> plan(int amount) { return head.plan(amount); }
void dispense(Map<Integer, Integer> plan) {
if (jamNext) {
jamNext = false;
throw new DispenseException("Notes jammed in the dispenser");
}
head.dispense(plan); // counts change only after the hardware succeeds
}
void jamNextDispense() { jamNext = true; }
void refill(int denom, int notes) { head.refill(denom, notes); }
int total() { return head.total(); }
}
// ===================== State pattern =====================
interface AtmState {
String name();
default void insertCard(Atm atm, Card card) { throw new AtmException("Cannot insert a card now (" + name() + ")"); }
default void enterPin(Atm atm, String pin) { throw new AtmException("Insert your card first"); }
default Map<Integer, Integer> withdraw(Atm atm, int amount) { throw new AtmException("Not authenticated"); }
default long balance(Atm atm) { throw new AtmException("Not authenticated"); }
default void ejectCard(Atm atm) { throw new AtmException("No card to eject"); }
}
static final class IdleState implements AtmState {
static final IdleState INSTANCE = new IdleState();
public String name() { return "IDLE"; }
public void insertCard(Atm atm, Card card) {
if (card.expiry().isBefore(atm.currentMonth)) throw new AtmException("Card expired");
if (atm.bank.isBlocked(card.number())) throw new AtmException("Card is blocked; contact your bank");
atm.card = card;
atm.wrongPins = 0;
atm.state = CardInsertedState.INSTANCE;
}
}
static final class CardInsertedState implements AtmState {
static final CardInsertedState INSTANCE = new CardInsertedState();
public String name() { return "CARD_INSERTED"; }
public void enterPin(Atm atm, String pin) {
if (atm.bank.verifyPin(atm.card.number(), pin)) {
atm.state = AuthenticatedState.INSTANCE;
return;
}
atm.wrongPins++;
if (atm.wrongPins >= Atm.MAX_PIN_ATTEMPTS) {
atm.bank.block(atm.card.number());
atm.endSession(); // card retained by the reader
throw new AtmException("Wrong PIN entered " + Atm.MAX_PIN_ATTEMPTS
+ " times; card retained and blocked. Contact your bank.");
}
throw new AtmException("Wrong PIN, " + (Atm.MAX_PIN_ATTEMPTS - atm.wrongPins) + " attempt(s) left");
}
public void ejectCard(Atm atm) { atm.endSession(); }
}
static final class AuthenticatedState implements AtmState {
static final AuthenticatedState INSTANCE = new AuthenticatedState();
public String name() { return "AUTHENTICATED"; }
public Map<Integer, Integer> withdraw(Atm atm, int amount) {
if (amount <= 0 || amount % 100 != 0) throw new AtmException("Amount must be a multiple of Rs 100");
if (amount > Atm.MAX_PER_TRANSACTION) throw new AtmException("Max Rs " + Atm.MAX_PER_TRANSACTION + " per transaction");
Map<Integer, Integer> notes = atm.dispenser.plan(amount) // 1. plan before touching the account
.orElseThrow(() -> new AtmException("ATM cannot dispense Rs " + amount + " right now"));
String txnId = atm.nextTxnId();
atm.bank.debit(atm.card.number(), amount, txnId); // 2. debit (idempotent)
try {
atm.dispenser.dispense(notes); // 3. dispense
} catch (DispenseException e) {
atm.bank.reverse(txnId); // 4. undo the debit
throw new AtmException("Cash could not be dispensed; Rs " + amount + " has been reversed to your account");
}
atm.journal.add(txnId + " WITHDRAW " + amount + " " + notes);
return notes;
}
public long balance(Atm atm) { return atm.bank.balance(atm.card.number()); }
public void ejectCard(Atm atm) { atm.endSession(); }
}
static final class OutOfServiceState implements AtmState {
static final OutOfServiceState INSTANCE = new OutOfServiceState();
public String name() { return "OUT_OF_SERVICE"; }
}
// ===================== Context =====================
static final class Atm {
static final int MAX_PIN_ATTEMPTS = 3;
static final int MAX_PER_TRANSACTION = 10_000;
static final int LOW_CASH_THRESHOLD = 1_000;
final String atmId;
final BankService bank;
final CashDispenser dispenser;
final YearMonth currentMonth;
final List<String> journal = new ArrayList<>();
AtmState state = IdleState.INSTANCE;
Card card;
int wrongPins;
private long txnSeq;
Atm(String atmId, BankService bank, CashDispenser dispenser, YearMonth currentMonth) {
this.atmId = atmId;
this.bank = bank;
this.dispenser = dispenser;
this.currentMonth = currentMonth;
}
synchronized void insertCard(Card c) { state.insertCard(this, c); }
synchronized void enterPin(String pin) { state.enterPin(this, pin); }
synchronized Map<Integer, Integer> withdraw(int amount) { return state.withdraw(this, amount); }
synchronized long balance() { return state.balance(this); }
synchronized void ejectCard() { state.ejectCard(this); }
synchronized String stateName() { return state.name(); }
synchronized void refill(int denomination, int notes) {
if (state != IdleState.INSTANCE && state != OutOfServiceState.INSTANCE) {
throw new AtmException("Session in progress");
}
dispenser.refill(denomination, notes);
state = IdleState.INSTANCE;
}
void endSession() {
card = null;
wrongPins = 0;
state = dispenser.total() < LOW_CASH_THRESHOLD ? OutOfServiceState.INSTANCE : IdleState.INSTANCE;
}
String nextTxnId() { return atmId + "-" + (++txnSeq); }
}
static void attempt(Runnable action) {
try {
action.run();
} catch (AtmException e) {
System.out.println(" ! " + e.getMessage());
}
}
public static void main(String[] args) {
InMemoryBank bank = new InMemoryBank();
bank.open("4111-0000-0000-1111", "1234", 25_000, 20_000);
bank.open("5222-0000-0000-2222", "4321", 8_000, 20_000);
Cassette head = new Cassette(500, 10);
head.linkTo(new Cassette(200, 3)).linkTo(new Cassette(100, 0));
Atm atm = new Atm("BLR-0042", bank, new CashDispenser(head), YearMonth.of(2026, 10));
Card riya = new Card("4111-0000-0000-1111", YearMonth.of(2029, 6));
atm.insertCard(riya);
attempt(() -> atm.enterPin("1111"));
atm.enterPin("1234");
System.out.println("Withdraw 600 -> " + atm.withdraw(600));
System.out.println("Withdraw 2500 -> " + atm.withdraw(2500));
attempt(() -> atm.withdraw(350));
atm.dispenser.jamNextDispense();
attempt(() -> atm.withdraw(1000));
attempt(() -> atm.withdraw(3000));
System.out.println("Balance -> Rs " + atm.balance());
System.out.println("Cash in ATM -> Rs " + atm.dispenser.total());
atm.ejectCard();
Card karan = new Card("5222-0000-0000-2222", YearMonth.of(2028, 1));
atm.insertCard(karan);
for (String pin : List.of("0000", "1111", "2222")) attempt(() -> atm.enterPin(pin));
attempt(() -> atm.insertCard(karan));
System.out.println("State: " + atm.stateName() + ", journal: " + atm.journal);
}
}
Demo Walkthrough
! Wrong PIN, 2 attempt(s) left
Withdraw 600 -> {200=3}
Withdraw 2500 -> {500=5}
! Amount must be a multiple of Rs 100
! Cash could not be dispensed; Rs 1000 has been reversed to your account
! ATM cannot dispense Rs 3000 right now
Balance -> Rs 21900
Cash in ATM -> Rs 2500
! Wrong PIN, 2 attempt(s) left
! Wrong PIN, 1 attempt(s) left
! Wrong PIN entered 3 times; card retained and blocked. Contact your bank.
! Card is blocked; contact your bank
State: IDLE, journal: [BLR-0042-1 WITHDRAW 600 {200=3}, BLR-0042-2 WITHDRAW 2500 {500=5}]
- Wrong PIN, then right PIN. The first attempt fails with two left; the second authenticates.
- ₹600 with no ₹100 notes. The ₹500 handler tries one note, the rest of the chain fails on ₹100, so it backs off to zero; the ₹200 handler serves three notes. A greedy chain would have refused this withdrawal.
- ₹2,500 comes out as five ₹500 notes.
- ₹350 is rejected locally; the bank is never called.
- Jam on ₹1,000. Plan {500=2}, debit with transaction id BLR-0042-3, dispense throws, the debit is reversed by id, and cassette counts are untouched because they only change after successful dispensing. The journal does not record it as a withdrawal.
- ₹3,000 exceeds the ₹2,500 left in the machine. The plan fails before the debit, so the customer's account is never touched.
- Balance is ₹25,000 − ₹600 − ₹2,500 = ₹21,900; the reversed ₹1,000 is back.
- Lockout. After ejecting Riya's card, the ATM returns to IDLE (₹2,500 is above the ₹1,000 low-cash threshold). Karan enters three wrong PINs; the bank blocks the card and the session ends. Trying the card again is refused by the blocked check at insertion, as it would be at any ATM, because the block lives at the bank.
Extensions Interviewers Ask
More transaction types
Balance inquiry, mini statement, PIN change, fund transfer, cash deposit on recycler ATMs. Once there are more than two, model each as a Transaction with execute(Atm) (Command), and have AuthenticatedState run whichever the user picks. Deposits reverse the dispenser direction: notes are validated by a chain of counterfeit detectors (Chain of Responsibility again) before crediting.
Cardless cash withdrawal (UPI ATM)
Some Indian ATMs support withdrawing cash by scanning a QR with a UPI app. That is a new entry path: IDLE → (QR shown) → AWAITING_UPI_APPROVAL → dispense. The dispenser, journal and reversal logic are reused unchanged. A new state and a new BankService call, nothing more, which is exactly the benefit of the State pattern.
Timeouts
If the user walks away mid-session, a timer returns the card and goes to IDLE; if they do not take the presented cash within a few seconds, the dispenser retracts it and the switch reverses the debit. Both are timer events handled by the current state.
Monitoring and low cash
Observer: the ATM publishes CashLow, CassetteEmpty, DispenseFailure events to the bank's monitoring system, which schedules cash-replenishment visits. Our endSession already moves the ATM out of service below a threshold.
Network partition
If the ATM sends a debit and the response never arrives, it does not know whether the account was debited. It must not dispense, and it must not simply retry with a new id. It retries with the same id (idempotent) or sends a reversal for that id. This is the same "unknown outcome" problem every payment system solves; see digital wallet design for how ledgers handle it.
Interview Tips for ATM LLD
A 45-minute structure
- Requirements and components (5 min). Denominations, limits, PIN attempts, out of service.
- State table (5 min). Four states, five actions.
- Dispenser chain (10 min). Greedy first, then the counterexample and the backing-off fix.
- Transaction safety (5 min). Plan, debit with id, dispense, reverse.
- Code and walkthrough (20 min).
Strong signals
- You keep PIN verification and blocking on the bank side.
- You plan notes before debiting and can explain why.
- You use a transaction id for idempotent debits and reversals.
- Cassette counts change only after the hardware reports success.
- You know greedy dispensing fails with limited notes.
Common mistakes
- Storing account balance inside the ATM object.
- Withdraw callable without authentication because state is a boolean that someone forgot to check.
- Debiting after dispensing, or not handling dispense failure at all.
- A dispenser loop that silently dispenses less than requested.
- Wrong-PIN counter only on the terminal, so a thief retries at the next ATM.
For more State pattern practice, the vending machine LLD is the closest sibling; for timed coding rounds, use the plan in the machine coding round guide.
Frequently Asked Questions
Design Money-Safe Systems in Interviews
Prepflix's course covers ATM, wallet and payment designs from LLD to HLD, with the idempotency and reversal reasoning that fintech interviewers at Razorpay, PhonePe and banks look for.
Explore the Prepflix Course →