LLD Series: ATM (Low-Level Design)
Overview
- 1. Problem statement and how it is asked
- 2. Requirements clarification
- 3. Entities, relationships, class diagram
- 4. Design patterns and why
- 5. Complete Java code
- 6. Walkthrough of key flows
- 7. Extensibility
- 8. Concurrency and edge cases
- 9. Follow-up questions and model answers
- 10. Common mistakes, red flags, rubric
Part of the Interview Prep series. Dotted-underline words are glossary terms: hover for the meaning or open the Glossary. Track your progress on the Interview Prep tracker. Series index: Start here.
1. Problem statement and how it is asked
Typical phrasing: "Design an ATM. A user inserts a card, enters a PIN, and can withdraw cash, deposit or check balance. The machine must dispense the right notes. Model the machine's behaviour so that invalid operations are impossible in the wrong state."
- Asked at: Visa, Mastercard, NPCI, Amex (very common in payments companies - they live next to ATM switches and card networks), PayU less so.
- Interviewer is checking: State pattern for the machine, Chain of Responsibility for denominations, a clean boundary between ATM and bank (ATM must not own balances or PINs), atomicity of "debit then dispense", and failure handling.
2. Requirements clarification
Functional
- Insert card -> enter PIN -> choose transaction -> eject card.
- Operations: withdraw, deposit, balance enquiry.
- PIN verified by the bank; 3 wrong attempts block the card.
- Cash dispensed using available denominations (INR 2000/500/200/100), largest first.
- Limits: multiple of 100, per-transaction limit (ATM), daily limit (bank).
- Every attempt is journaled (SUCCESS / FAILED).
- Machine goes OUT_OF_SERVICE when empty.
Non-functional
- Operations are rejected with a clear error in wrong states (cannot withdraw before PIN).
- Correctness of money: never dispense without debit, never debit without dispense (compensate).
- Extensible denominations / transaction types.
Assumptions: one user at a time per machine (so ATM methods are synchronized); the bank is a remote system behind an interface (BankService); deposits are credited instantly (no envelope/cheque clearing); amounts are whole rupees (long).
Out of scope: receipts printing hardware, session timeouts (mention), cardless/UPI withdrawals, currency other than INR, deposit note validation, network failures between ATM and bank (covered in follow-ups).
3. Entities, relationships, class diagram
+----------------------------+
| Atm | (State context)
|----------------------------|
| state: AtmState |
| card: Card |
| journal: List<Transaction> |
+------+-----------+---------+
| |
delegates to | | has-a
v v
+-------------------+ +-----------------+ +----------------------+
|<<interface>> | | CashDispenser |------->| DenominationHandler |
| AtmState | | inventory | chain | 2000 -> 500 -> 200 ->|
+----^---^---^---^--+ | plan()/commit() | | 100 |
| | | | +-----------------+ +----------------------+
IdleState---+ | | +--OutOfServiceState
CardInsertedState AuthenticatedState
Atm --uses--> <<interface>> BankService <|-- InMemoryBankService
Atm --records--> Transaction(id,type,amount,status) Card(number)
State transitions:
insertCard PIN ok eject
IDLE ------------------> CARD_INSERTED ----------> AUTHENTICATED ------> IDLE
^ | wrong PIN x3 (card retained) |
+-------------------------+ | cash == 0
v
OUT_OF_SERVICE
4. Design patterns and why
| Pattern | Where | Why / rejected alternative |
|---|---|---|
| State | AtmState + 4 states | Behaviour depends on state; removes giant if (state == X) chains in each method. Default methods in the interface reject everything, so each state only declares what it allows (safe by default). Rejected: enum + switch in each method - every new state touches every method. |
| Chain of Responsibility | DenominationHandler | Each link handles one note type and passes the remainder. Adding a Rs 50 note = add one link, no change elsewhere. Rejected: nested loops over a sorted map (works, but not extensible per-denomination rules, e.g. "never dispense more than 10 x Rs 100"). |
| Facade | Atm | Single API for the keypad/UI; states are internal. |
| Port/Adapter | BankService | ATM does not know whether the bank is a switch, ISO 8583 gateway or in-memory fake. |
| Singleton (stateless states) | XState.INSTANCE | States hold no data; the context holds data. Alternative: new state per transition (needed if a state carries data such as an attempt counter). |
| Command (mention) | Transaction | Could model Withdraw/Deposit as TransactionCommand objects with execute()/rollback() if ops grow. |
Greedy caveat to state aloud: greedy-by-largest is optimal for the canonical Indian note system, but with limited inventory it can fail where a combination exists (e.g. need 600 with notes {500 x 0, 200 x 3}); the fix is a bounded-coin DP or trying handlers with a "skip" option. Mention, then keep greedy for the 60-min round.
5. Complete Java code
Default package; javac *.java && java Main.
5.1 Core: exceptions, card, bank port
// AtmException.java
public class AtmException extends RuntimeException {
public AtmException(String message) { super(message); }
}
// Card.java
public final class Card {
private final String number;
public Card(String number) { this.number = number; }
public String number() { return number; }
public String masked() { return "****" + number.substring(number.length() - 4); }
}
// BankService.java
/** Port to the issuing bank / switch. ATM never stores PINs or balances. */
public interface BankService {
/** @throws AtmException if card blocked; returns false for a wrong PIN (and counts the failure). */
boolean verifyPin(String cardNumber, String pin);
long balance(String cardNumber);
/** Atomic at the bank: checks balance and daily limit, then debits. */
void debit(String cardNumber, long amount, String txnId);
void credit(String cardNumber, long amount, String txnId);
int remainingPinAttempts(String cardNumber);
}
// InMemoryBankService.java
import java.util.*;
public final class InMemoryBankService implements BankService {
private static final int MAX_PIN_ATTEMPTS = 3;
private final long dailyWithdrawLimit;
private static final class Account {
final String pin; long balance; long withdrawnToday; int failedAttempts; boolean blocked;
Account(String pin, long balance) { this.pin = pin; this.balance = balance; }
}
private final Map<String, Account> accounts = new HashMap<>();
public InMemoryBankService(long dailyWithdrawLimit) { this.dailyWithdrawLimit = dailyWithdrawLimit; }
public synchronized void openAccount(String card, String pin, long balance) {
accounts.put(card, new Account(pin, balance));
}
private Account acc(String card) {
Account a = accounts.get(card);
if (a == null) throw new AtmException("Unknown card");
return a;
}
@Override public synchronized boolean verifyPin(String card, String pin) {
Account a = acc(card);
if (a.blocked) throw new AtmException("Card is blocked. Contact your bank.");
if (a.pin.equals(pin)) { a.failedAttempts = 0; return true; }
if (++a.failedAttempts >= MAX_PIN_ATTEMPTS) a.blocked = true;
return false;
}
@Override public synchronized int remainingPinAttempts(String card) {
return Math.max(0, MAX_PIN_ATTEMPTS - acc(card).failedAttempts);
}
@Override public synchronized long balance(String card) { return acc(card).balance; }
@Override public synchronized void debit(String card, long amount, String txnId) {
Account a = acc(card);
if (a.balance < amount) throw new AtmException("Insufficient funds");
if (a.withdrawnToday + amount > dailyWithdrawLimit) throw new AtmException("Daily withdrawal limit exceeded");
a.balance -= amount; a.withdrawnToday += amount;
}
@Override public synchronized void credit(String card, long amount, String txnId) {
Account a = acc(card);
a.balance += amount;
}
}
5.2 Cash dispenser (Chain of Responsibility)
// DenominationHandler.java
import java.util.Map;
/** Chain of Responsibility link: takes as many notes of its denomination as it can, passes the rest on. */
public final class DenominationHandler {
private final int denomination;
private DenominationHandler next;
public DenominationHandler(int denomination) { this.denomination = denomination; }
public DenominationHandler linkWith(DenominationHandler next) { this.next = next; return next; }
/** @return amount still left to dispense after this link and all following links. Pure: works on a snapshot. */
public long handle(long remaining, Map<Integer, Integer> inventory, Map<Integer, Integer> plan) {
int available = inventory.getOrDefault(denomination, 0);
int use = (int) Math.min(remaining / denomination, available);
if (use > 0) {
plan.put(denomination, use);
remaining -= (long) use * denomination;
}
return (remaining > 0 && next != null) ? next.handle(remaining, inventory, plan) : remaining;
}
}
// CashDispenser.java
import java.util.*;
/** Owns note inventory. Two-phase: plan() is a dry run, commit() mutates. */
public final class CashDispenser {
private final Map<Integer, Integer> inventory = new TreeMap<>(Comparator.reverseOrder());
private DenominationHandler head;
public CashDispenser(Map<Integer, Integer> initial) {
inventory.putAll(initial);
DenominationHandler prev = null;
for (int denom : inventory.keySet()) { // descending order
DenominationHandler h = new DenominationHandler(denom);
if (prev == null) head = h; else prev.linkWith(h);
prev = h;
}
}
public Map<Integer, Integer> plan(long amount) {
Map<Integer, Integer> plan = new LinkedHashMap<>();
long left = head.handle(amount, inventory, plan);
if (left != 0) throw new AtmException("Cannot dispense " + amount + " with available notes");
return plan;
}
public void commit(Map<Integer, Integer> plan) {
plan.forEach((d, n) -> inventory.merge(d, -n, Integer::sum));
}
public long totalCash() {
return inventory.entrySet().stream().mapToLong(e -> (long) e.getKey() * e.getValue()).sum();
}
@Override public String toString() { return inventory.toString(); }
}
5.3 Transaction journal
// Transaction.java
import java.time.Instant;
public final class Transaction {
public enum Type { WITHDRAWAL, DEPOSIT, BALANCE_ENQUIRY }
public enum Status { SUCCESS, FAILED }
private final String id; private final Type type; private final long amount;
private final Status status; private final String note; private final Instant at;
public Transaction(String id, Type type, long amount, Status status, String note, Instant at) {
this.id = id; this.type = type; this.amount = amount; this.status = status; this.note = note; this.at = at;
}
public String id() { return id; }
@Override public String toString() {
return id + " " + type + " " + amount + " " + status + (note.isEmpty() ? "" : " (" + note + ")");
}
}
5.4 State pattern
// AtmState.java
/** State pattern. Every operation is rejected by default; each concrete state overrides what it allows. */
public interface AtmState {
String name();
default void insertCard(Atm atm, Card card) { reject("insert card"); }
default void enterPin(Atm atm, String pin) { reject("enter PIN"); }
default void withdraw(Atm atm, long amount) { reject("withdraw"); }
default void deposit(Atm atm, long amount) { reject("deposit"); }
default long checkBalance(Atm atm) { reject("check balance"); return 0; }
default void ejectCard(Atm atm) { reject("eject card"); }
private void reject(String op) { throw new AtmException("Cannot " + op + " in state " + name()); }
}
// IdleState.java
public final class IdleState implements AtmState {
public static final IdleState INSTANCE = new IdleState();
private IdleState() {}
@Override public String name() { return "IDLE"; }
@Override public void insertCard(Atm atm, Card card) {
atm.attachCard(card);
atm.setState(CardInsertedState.INSTANCE);
System.out.println(" [ATM] Card " + card.masked() + " accepted. Enter PIN.");
}
}
// CardInsertedState.java
public final class CardInsertedState implements AtmState {
public static final CardInsertedState INSTANCE = new CardInsertedState();
private CardInsertedState() {}
@Override public String name() { return "CARD_INSERTED"; }
@Override public void enterPin(Atm atm, String pin) {
BankService bank = atm.bank();
String card = atm.card().number();
boolean ok;
try {
ok = bank.verifyPin(card, pin);
} catch (AtmException blocked) { // card blocked: retain card, go idle
atm.releaseCard();
atm.setState(IdleState.INSTANCE);
throw blocked;
}
if (ok) {
atm.setState(AuthenticatedState.INSTANCE);
System.out.println(" [ATM] PIN OK. Choose a transaction.");
} else {
int left = bank.remainingPinAttempts(card);
if (left == 0) { atm.releaseCard(); atm.setState(IdleState.INSTANCE);
throw new AtmException("Wrong PIN. Card blocked and retained."); }
throw new AtmException("Wrong PIN. Attempts left: " + left);
}
}
@Override public void ejectCard(Atm atm) { atm.releaseCard(); atm.setState(IdleState.INSTANCE); System.out.println(" [ATM] Card ejected."); }
}
// AuthenticatedState.java
import java.util.Map;
public final class AuthenticatedState implements AtmState {
public static final AuthenticatedState INSTANCE = new AuthenticatedState();
private AuthenticatedState() {}
@Override public String name() { return "AUTHENTICATED"; }
@Override public void withdraw(Atm atm, long amount) {
if (amount <= 0 || amount % Atm.MIN_NOTE != 0)
throw new AtmException("Amount must be a positive multiple of " + Atm.MIN_NOTE);
if (amount > Atm.PER_TXN_LIMIT) throw new AtmException("Per-transaction limit is " + Atm.PER_TXN_LIMIT);
Map<Integer, Integer> plan = atm.dispenser().plan(amount); // 1. can we physically pay? (dry run)
String txnId = atm.nextTxnId();
try {
atm.bank().debit(atm.card().number(), amount, txnId); // 2. money moves at the bank
} catch (AtmException e) {
atm.record(Transaction.Type.WITHDRAWAL, amount, Transaction.Status.FAILED, e.getMessage(), txnId);
throw e;
}
atm.dispenser().commit(plan); // 3. physically dispense
atm.record(Transaction.Type.WITHDRAWAL, amount, Transaction.Status.SUCCESS, "", txnId);
System.out.println(" [ATM] Dispensed " + amount + " as " + plan);
if (atm.dispenser().totalCash() == 0) atm.setState(OutOfServiceState.INSTANCE);
}
@Override public void deposit(Atm atm, long amount) {
if (amount <= 0) throw new AtmException("Invalid amount");
String txnId = atm.nextTxnId();
atm.bank().credit(atm.card().number(), amount, txnId);
atm.record(Transaction.Type.DEPOSIT, amount, Transaction.Status.SUCCESS, "", txnId);
}
@Override public long checkBalance(Atm atm) {
long b = atm.bank().balance(atm.card().number());
atm.record(Transaction.Type.BALANCE_ENQUIRY, 0, Transaction.Status.SUCCESS, "", atm.nextTxnId());
return b;
}
@Override public void ejectCard(Atm atm) { atm.releaseCard(); atm.setState(IdleState.INSTANCE); System.out.println(" [ATM] Card ejected. Thank you."); }
}
// OutOfServiceState.java
public final class OutOfServiceState implements AtmState {
public static final OutOfServiceState INSTANCE = new OutOfServiceState();
private OutOfServiceState() {}
@Override public String name() { return "OUT_OF_SERVICE"; }
@Override public void ejectCard(Atm atm) { atm.releaseCard(); System.out.println(" [ATM] Card ejected. ATM out of cash."); }
}
5.5 Context
// Atm.java
import java.time.Clock;
import java.time.Instant;
import java.util.*;
/** Context of the State pattern. Single physical user at a time => methods are synchronized on the machine. */
public final class Atm {
public static final int MIN_NOTE = 100;
public static final long PER_TXN_LIMIT = 10_000;
private final BankService bank;
private final CashDispenser dispenser;
private final Clock clock;
private final List<Transaction> journal = new ArrayList<>();
private AtmState state = IdleState.INSTANCE;
private Card card;
private int txnSeq;
public Atm(BankService bank, CashDispenser dispenser, Clock clock) {
this.bank = bank; this.dispenser = dispenser; this.clock = clock;
}
// ---- public API: pure delegation to current state ----
public synchronized void insertCard(Card c) { state.insertCard(this, c); }
public synchronized void enterPin(String pin) { state.enterPin(this, pin); }
public synchronized void withdraw(long amount) { state.withdraw(this, amount); }
public synchronized void deposit(long amount) { state.deposit(this, amount); }
public synchronized long checkBalance() { return state.checkBalance(this); }
public synchronized void ejectCard() { state.ejectCard(this); }
public synchronized String stateName() { return state.name(); }
public synchronized List<Transaction> journal() { return List.copyOf(journal); }
// ---- package-level hooks used by states ----
void setState(AtmState s) { this.state = s; }
void attachCard(Card c) { this.card = c; }
void releaseCard() { this.card = null; }
Card card() { return card; }
BankService bank() { return bank; }
CashDispenser dispenser() { return dispenser; }
String nextTxnId() { return "TXN" + (++txnSeq); }
void record(Transaction.Type t, long amt, Transaction.Status s, String note, String id) {
journal.add(new Transaction(id, t, amt, s, note, Instant.now(clock)));
}
}
5.6 Demo driver
// Main.java
import java.time.Clock;
import java.util.Map;
public class Main {
static void attempt(String label, Runnable r) {
try { r.run(); }
catch (AtmException e) { System.out.println(" [" + label + "] rejected: " + e.getMessage()); }
}
public static void main(String[] args) {
InMemoryBankService bank = new InMemoryBankService(25_000);
bank.openAccount("4111111111111111", "1234", 50_000);
bank.openAccount("5500000000000004", "9999", 10_000);
CashDispenser cash = new CashDispenser(Map.of(2000, 20, 500, 20, 200, 10, 100, 20));
Atm atm = new Atm(bank, cash, Clock.systemUTC());
System.out.println("Initial cash " + cash.totalCash() + " " + cash);
System.out.println("\n== Scenario 1: happy path ==");
attempt("withdraw while idle", () -> atm.withdraw(500));
atm.insertCard(new Card("4111111111111111"));
attempt("wrong pin", () -> atm.enterPin("0000"));
atm.enterPin("1234");
System.out.println(" Balance: " + atm.checkBalance());
atm.withdraw(3700);
attempt("bad amount", () -> atm.withdraw(150));
attempt("over per-txn limit", () -> atm.withdraw(12_000));
atm.withdraw(8_000);
atm.withdraw(9_000); // 3700+8000+9000 = 20700 (daily limit 25000)
attempt("daily limit", () -> atm.withdraw(5_000)); // would be 25700
atm.deposit(1_000);
System.out.println(" Balance: " + atm.checkBalance());
atm.ejectCard();
System.out.println(" State: " + atm.stateName() + ", cash left " + cash.totalCash() + " " + cash);
System.out.println("\n== Scenario 2: card blocked after 3 wrong PINs ==");
atm.insertCard(new Card("5500000000000004"));
attempt("pin1", () -> atm.enterPin("1"));
attempt("pin2", () -> atm.enterPin("2"));
attempt("pin3", () -> atm.enterPin("3"));
System.out.println(" State: " + atm.stateName());
atm.insertCard(new Card("5500000000000004"));
attempt("pin4", () -> atm.enterPin("9999"));
System.out.println(" State: " + atm.stateName());
System.out.println("\n== Scenario 3: insufficient notes ==");
Atm small = new Atm(bank, new CashDispenser(Map.of(500, 1, 100, 1)), Clock.systemUTC());
small.insertCard(new Card("4111111111111111"));
small.enterPin("1234");
attempt("cannot dispense", () -> small.withdraw(1_000));
small.withdraw(600);
System.out.println(" State after last notes: " + small.stateName());
System.out.println("\n== Journal of main ATM ==");
atm.journal().forEach(t -> System.out.println(" " + t));
}
}
Real output (from java Main)
Initial cash 54000 {2000=20, 500=20, 200=10, 100=20}
== Scenario 1: happy path ==
[withdraw while idle] rejected: Cannot withdraw in state IDLE
[ATM] Card ****1111 accepted. Enter PIN.
[wrong pin] rejected: Wrong PIN. Attempts left: 2
[ATM] PIN OK. Choose a transaction.
Balance: 50000
[ATM] Dispensed 3700 as {2000=1, 500=3, 200=1}
[bad amount] rejected: Amount must be a positive multiple of 100
[over per-txn limit] rejected: Per-transaction limit is 10000
[ATM] Dispensed 8000 as {2000=4}
[ATM] Dispensed 9000 as {2000=4, 500=2}
[daily limit] rejected: Daily withdrawal limit exceeded
Balance: 30300
[ATM] Card ejected. Thank you.
State: IDLE, cash left 33300 {2000=11, 500=15, 200=9, 100=20}
== Scenario 2: card blocked after 3 wrong PINs ==
[ATM] Card ****0004 accepted. Enter PIN.
[pin1] rejected: Wrong PIN. Attempts left: 2
[pin2] rejected: Wrong PIN. Attempts left: 1
[pin3] rejected: Wrong PIN. Card blocked and retained.
State: IDLE
[ATM] Card ****0004 accepted. Enter PIN.
[pin4] rejected: Card is blocked. Contact your bank.
State: IDLE
== Scenario 3: insufficient notes ==
[ATM] Card ****1111 accepted. Enter PIN.
[ATM] PIN OK. Choose a transaction.
[cannot dispense] rejected: Cannot dispense 1000 with available notes
[ATM] Dispensed 600 as {500=1, 100=1}
State after last notes: OUT_OF_SERVICE
== Journal of main ATM ==
TXN1 BALANCE_ENQUIRY 0 SUCCESS
TXN2 WITHDRAWAL 3700 SUCCESS
TXN3 WITHDRAWAL 8000 SUCCESS
TXN4 WITHDRAWAL 9000 SUCCESS
TXN5 WITHDRAWAL 5000 FAILED (Daily withdrawal limit exceeded)
TXN6 DEPOSIT 1000 SUCCESS
TXN7 BALANCE_ENQUIRY 0 SUCCESS
6. Walkthrough of key flows
Withdraw 3700
User Atm(Authenticated) CashDispenser BankService
|-withdraw->| | |
| |-validate(multiple of 100, <= per-txn limit) |
| |--plan(3700)----------->| chain 2000->500->200->100 (dry run on snapshot)
| |<--{2000:1,500:3,200:1}-| |
| |--------------debit(card,3700,txnId)-------->| balance + daily-limit check (atomic)
| |<-------------------------ok / AtmException--|
| |--commit(plan)--------->| inventory -= plan (physical dispense)
| |-record(SUCCESS) | |
|<-cash-----|
failure at debit => record(FAILED), nothing dispensed, nothing changed
Card + PIN
insertCard -> Idle.insertCard: attachCard, state=CardInserted
enterPin -> bank.verifyPin
true -> state=Authenticated
false -> attempts left? if 0 => card retained, state=Idle
Order of steps matters: plan (no side effects) -> debit at bank -> commit dispense. The only step that can fail after the money moved is the physical commit; in real hardware that is "dispense jam" and handled with a reversal (bank.credit / reversal message ISO 8583 0400). In this code commit cannot fail after plan because ATM operations are serialised.
7. Extensibility
- New denomination (Rs 50 / Rs 20): add key to the inventory map; the constructor builds a link per denomination. No code change.
- Per-denomination rule (max 10 notes of 100): subclass/extend
DenominationHandlerwith a cap; chain order unchanged. - New transaction (PIN change, mini statement, transfer): add a method to
AtmStatewith default reject, implement inAuthenticatedState(or a newPinChangeState). Existing states untouched. - New state (MAINTENANCE, SESSION_TIMEOUT): new class; transitions only where entered.
- Different bank/switch: implement
BankService. - Cardless / UPI withdrawal: new
CardlessAuthStatereplacingCardInsertedState, sameAuthenticatedState.
8. Concurrency and edge cases
- Machine-level lock: a physical ATM serves one customer;
synchronizedon theAtmmakes the state transitions atomic and also protectsCashDispenser(only reachable through Atm). Finer locks would be pointless. - Bank side is where real concurrency lives: the same account may be used from two ATMs.
debitis atomic at the bank (heresynchronizedon service; in production a conditional updateUPDATE acct SET bal = bal - ? WHERE id=? AND bal >= ?plus a daily-limit row). - Failure between debit and dispense: timeout from bank (unknown outcome) -> do NOT dispense, send reversal; the txn id makes the reversal idempotent.
- Card retained after 3 wrong PINs; state goes back to IDLE.
- Invalid amounts: not multiple of 100, <= 0, above per-txn limit, above balance, above daily limit, not enough notes -> FAILED journal entries where money was attempted.
- Out of cash moves state to OUT_OF_SERVICE (cannot insert card).
- Session timeout/abandoned card: scheduler calls
ejectCard()after N seconds (mention; it is another trigger into the same state machine). - PIN never stored/logged by ATM; card number masked in logs (PCI-DSS).
9. Follow-up questions and model answers
- Why State pattern and not an enum? Behaviour per state is encapsulated; adding state does not modify existing ones (OCP); illegal operations are rejected by default.
- What if the denomination greedy fails? Show the bounded knapsack/DP alternative, or try next-lower combos with backtracking; mention it as an optimisation and keep an
InsufficientNoteserror. - How do you make debit+dispense atomic? Cannot be truly atomic across bank and hardware. Use a two-phase approach: authorise/hold at bank -> dispense -> confirm; if dispense fails, reverse. Persist an ATM-side journal (write-ahead) with txn id so after a crash the ATM replays reversals on boot.
- Network timeout to bank? Idempotent reversal keyed by stan/txnId; ATM retries reversal until acked; never dispense on an unknown outcome.
- How to persist / distribute? ATM keeps local journal (SQLite) and inventory. Central side:
account,card,txntables; PIN stored as HSM-verified PIN block, never in the DB; txn id = (terminalId, STAN, date) unique. Many ATMs = many independent clients to the bank service, so scale the bank horizontally, shard accounts by account id, use optimistic concurrency on balances. - Add multi-currency / foreign cards? The
BankServicebecomes a router by BIN (first 6 digits) to the right network (Visa, Mastercard, RuPay/NPCI); that is exactly what a switch does - a Strategy/Factory by BIN. - Security? Encrypt PIN at the keypad (PIN block, ISO 9564), mask PAN, tamper detection state, limited retries, no PIN in logs.
- Cash replenishment? Admin state with
load(denom, count)guarded by role; low-cash alert via Observer.
10. Common mistakes, red flags, rubric
Red flags
if (state == ...)in every method or one boolean per state (isCardInserted,isAuthenticated).- ATM class holding PIN/balance (no bank boundary).
- Debit after dispensing with no compensation; ignoring failure paths.
- Hard-coded
while(amount>=2000)blocks for each note. - States having public setters that mutate everything; states created via
newon each call with no reason. - Not handling wrong-PIN counting/blocking.
Mini rubric (20 pts)
| Area | Points |
|---|---|
| Requirements + assumptions | 3 |
| State pattern implemented correctly (illegal ops blocked) | 5 |
| Denomination chain (dry-run plan then commit) | 4 |
| Bank boundary, failure ordering and compensation | 4 |
| Runnable demo covering PIN block, limits, no-notes | 2 |
| Extensibility and follow-ups | 2 |