← Back to Design & Development
Low-Level Design

ATM System

A real ATM — State machine for the device, Strategy for transactions, Chain of Responsibility for cash dispensing, full Java code & every gap in Grokking's reference plugged.

Step 1

Clarify Requirements

Picture a Sunday night ATM lobby in Bandra. Raj wants ₹2,000 in cash, an old uncle is depositing a check, and a college kid is checking his balance. Inside the machine: cassettes of ₹500 and ₹100 notes, a card reader, a screen, a printer that prints receipts. The software has to drive every component, talk to the bank network, and never — under any circumstance — eat a card with money still hanging in the dispenser. That's the requirements bar.

✅ Functional Requirements

  • Authenticate the customer with card + 4-digit PIN (3 attempts, then card retained)
  • Five transactions: balance inquiry, cash withdraw, cash deposit, check deposit, transfer
  • Cash dispense respecting available denominations (₹500, ₹100) — partial-fail safe
  • Print a receipt for every transaction (or skip if printer is out)
  • Operator can power on/off, refill cash cassettes, refill paper, take out deposits
  • Bank manager can pull reports on totals deposited / withdrawn / cash remaining
  • Internal log of all transactions and hardware faults

🎯 Non-Functional & 🚫 Out of Scope

  • Single user at a time — but per-account locking inside the bank
  • Atomicity — the bank debit and the cash dispense must never disagree
  • Latency < 5s for the bank round trip; otherwise abort
  • Auditability — every state transition logged

🚫 Out of scope

  • Card hardware drivers / EMV chip protocol details
  • Cross-bank network routing (we model the Bank as one boundary)
  • Anti-skimming / anti-fraud heuristics
  • Multi-currency / FX
Interviewer trick: They love the question "what happens if your card is in the slot but the dispenser jams mid-withdrawal?" If you don't have a state for that, you've already failed. State Pattern is the answer.
Step 2

Actors & Use Cases

Three humans interact with the ATM: the Customer who came for money, the Operator (the cash-truck guy who refills it), and the Bank Manager who pulls reports remotely. The Bank itself — the back office that holds the actual account balance — is a silent system actor; the ATM never holds the source of truth on money.

flowchart LR C([Customer]) O([Operator]) M([Bank Manager]) B([Bank Network]) C --> BI[Balance Inquiry] C --> WD[Withdraw Cash] C --> DC[Deposit Cash] C --> DK[Deposit Check] C --> TR[Transfer] O --> ON[Power On / Off] O --> RC[Refill Cash] O --> RP[Refill Paper / Ink] O --> CO[Collect Deposits] M --> RD[Reports — deposits/withdrawals] M --> CR[Check Remaining Cash] B --> AU[Authenticate Card+PIN] B --> DB[Debit / Credit Account] style C fill:#e8743b,stroke:#e8743b,color:#fff style O fill:#4a90d9,stroke:#4a90d9,color:#fff style M fill:#9b72cf,stroke:#9b72cf,color:#fff style B fill:#38b265,stroke:#38b265,color:#fff

Customer

Holds the card. Inserts it, types the PIN, picks a transaction, takes the cash & receipt.

Operator

Field staff. Refills cash cassettes, paper rolls, removes deposit envelopes. Has a special key-switch.

Bank Manager

Remote — pulls totals and remaining-cash reports through a back-office portal.

Bank Network

The silent system actor. Holds the source of truth on accounts. Authenticates and authorizes every debit.

Step 3

Story — From a Cash Drawer to an ATM

To get to the right design, start with the simplest thing that could work, watch each break, and let each break suggest a class. Three passes: the naive design, the mental split, and the production shape.

Pass 1 — The naive design (and why it breaks)

Start with one class — ATM — that has a card reader, a cash drawer, and a single method withdraw(card, pin, amount). The method authenticates, debits the account, and opens the drawer. Done in 30 lines.

flowchart LR Customer --> ATM[ATM monolith] ATM --> Bank[(Bank account)] ATM --> Drawer[Cash drawer] style ATM fill:#e05252,stroke:#e05252,color:#fff

Then reality hits:

💥 Drawer jams

The bank already debited ₹2,000 but the dispenser failed to release any notes. Now Raj's account is short by ₹2,000 and the ATM doesn't know how to recover. No transaction state machine.

💥 Wrong PIN three times

The naive code just rejects each attempt. Anyone who steals the card has unlimited tries. No lockout / retain logic.

💥 Need ₹2,300 in ₹500 + ₹100

The ATM has plenty of ₹500 but ran out of ₹100. Naive code says "out of cash" and aborts. The customer wanted ₹2,300 — not ₹2,000? No denomination algorithm.

💥 Card forgotten in slot

Customer walked away after the cash. Card sits in the reader, next person grabs it, identity theft. No card retention timeout.

💥 New transaction type

Bank wants to add "mobile-recharge" as a transaction. The monolithic withdraw method has to grow a giant if/else. No transaction abstraction.

💥 Network timeout mid-debit

Bank reply lost. ATM doesn't know if the debit actually happened. No idempotency / no reversal protocol.

Pass 2 — The mental split

Two concerns are conflated in the monolith:

📟 Device plane

The physical ATM and its lifecycle. What state is the machine in right now? IDLE → CARD_INSERTED → AUTHENTICATING → SELECTING_TXN → PROCESSING → DISPENSING → RECEIPT → DONE. Hardware events drive transitions; the State Pattern lives here.

🏦 Transaction plane

What the customer is trying to do. Withdraw, Deposit, Transfer, BalanceInquiry — each is a Strategy with the same shape (execute()). Pricing, debiting, dispensing — all per-strategy.

Pass 3 — The production shape

Each numbered box below earns its place by fixing one of Pass 1's failures. Card colors match the diagram fills.

flowchart TB C([Customer]) C --> CR[① CardReader] CR --> ATM[② ATM Singleton
State Machine] ATM --> KP[③ Keypad] ATM --> SC[④ Screen] ATM --> TX[⑤ Transaction Strategy] TX --> WD2[Withdraw] TX --> DEP[Deposit] TX --> TR2[Transfer] TX --> BI2[BalanceInquiry] TX --> BN[⑥ BankAdapter] BN --> BANK[(Bank Network)] ATM --> CD[⑦ CashDispenser
Chain of Responsibility] ATM --> DS[⑧ DepositSlot] ATM --> PR[⑨ Printer] ATM --> LOG[⑩ AuditLog] style CR fill:#e8743b,stroke:#e8743b,color:#fff style ATM fill:#4a90d9,stroke:#4a90d9,color:#fff style KP fill:#9b72cf,stroke:#9b72cf,color:#fff style SC fill:#9b72cf,stroke:#9b72cf,color:#fff style TX fill:#38b265,stroke:#38b265,color:#fff style BN fill:#3cbfbf,stroke:#3cbfbf,color:#fff style CD fill:#d4a838,stroke:#d4a838,color:#000 style DS fill:#9b72cf,stroke:#9b72cf,color:#fff style PR fill:#9b72cf,stroke:#9b72cf,color:#fff style LOG fill:#e05252,stroke:#e05252,color:#fff

Component-by-component — what each numbered box does

CardReader

The slot. Reads card details, tracks "is card in slot", and can retain the card by physically swallowing it (no return motor). Triggers the IDLE → CARD_INSERTED transition.

Solves: Without a separate reader with a retain capability, the "3 wrong PINs in a row" rule has nowhere to live; the ATM either keeps spitting the card back or loses the security feature entirely.

ATM (Singleton + State Machine)

One physical device, one in-memory instance. The state field drives every screen, every keypad input, every dispense. Illegal events (e.g., "dispense" before "authenticate") are rejected by the current state's handler.

Solves: Without a state machine, the dispenser jam, network timeout, and "card forgotten" scenarios become a maze of boolean flags. The state is the single truth.

Keypad

Captures PIN entries and amount entries. Masks PIN on Screen. Handles the cancel button which is a "back to IDLE" event.

Solves: The cancel button is a hardware event that can fire from any state — modeling the keypad as an event source means the state machine handles it once, not in every screen.

Screen

Displays the menu, prompts, and confirmation messages. The state machine pushes screen text whenever it transitions.

Solves: Without a Screen abstraction, every state would have to remember to call the right print statement. Pulling the screen out lets the state simply say "show me the deposit confirmation".

Transaction (Strategy)

Abstract Transaction.execute(ATM, Account); concrete subclasses: Withdraw, CashDeposit, CheckDeposit, Transfer, BalanceInquiry. Each has the same execute signature; the ATM just calls it without knowing which type.

Solves: Adding a new transaction (e.g., MobileRecharge) is one new class. The Grokking design has the same hierarchy; what we add is a real execute() that sequences "ask bank → debit → dispense → receipt → log".

BankAdapter

The boundary to the bank network. Speaks ISO-8583 or whatever; exposes authenticate(Card, pin), authorizeDebit(Account, amount), commitDebit(txnId), reverseDebit(txnId). Two-phase — authorize holds the funds, commit finalizes, reverse rolls back if dispense fails.

Solves: The "drawer jams after debit" scenario is exactly what 2-phase fixes. Authorize first; only commit if cash actually came out.

CashDispenser (Chain of Responsibility)

For ₹2,300 with cassettes of ₹500 + ₹100, the dispenser is a chain: ₹500Handler peels off as many ₹500s as possible, passes the remainder to ₹100Handler. If the remainder isn't divisible by ₹100, the entire chain fails atomically (no partial cash).

Solves: Grokking's design has fields like totalFiveDollarBills and totalTwentyDollarBills with no algorithm. Real ATMs need this exact chain.

DepositSlot

Two subclasses — CashDepositSlot (counts notes), CheckDepositSlot (scans MICR). Adds the deposit to a holding bin; the bank only credits the account after physical verification by the operator.

Solves: Captures the "deposits aren't instant" rule from requirements without spreading it through the code.

Printer

Prints receipts. Tracks paper level. If out of paper, the ATM completes the transaction but skips the receipt and logs a "needs refill" alert.

Solves: A real ATM must not abort a successful debit just because paper ran out. The Printer's "graceful degrade" is a small thing that an interviewer probes for.

AuditLog

Every state transition, every transaction, every hardware fault — all written to a tamper-evident log on disk and replicated to the bank. The log is what lets the manager trust the totals report.

Solves: Without an explicit log component, audit becomes "scattered println". With it, "show me yesterday's failed dispenses" is one query.

So in short — the ATM is a state machine that orchestrates four physical devices and one network adapter. Transactions are pluggable Strategies; cash dispensing is a denomination chain; bank calls are 2-phase so a jam can roll back. Every observable event lands in an audit log.
Step 4

Core Entities

EntityResponsibilityKey Fields
ATMSingleton — holds devices, current state, current sessionString atmId, Address location, ATMState state, Session session
SessionOne customer's interaction (card insert → eject)Card card, Customer customer, List<Transaction> txns, int pinAttempts
CardThe customer's debit cardString cardNumber, String customerName, Date expiry, String pinHash
CustomerThe cardholderString name, String email, CustomerStatus status
AccountAbstract — holds balanceString accountNumber, BigDecimal balance, BigDecimal available
SavingAccount / CheckingAccountConcrete — adds limits / debit-card link(plus subclass-specific fields)
TransactionAbstract Strategy — one customer actionString txnId, TransactionStatus status, BigDecimal amount
CardReaderCard slot deviceboolean cardInside, boolean retained
CashDispenserChain of denomination handlersList<CashCassette> cassettes
BankAdapterBoundary to bank network — 2-phase debit(stateless)
Step 5

ER / Schema Diagram

Persistence shape: most state lives at the Bank, not the ATM. The ATM only persists its own audit log and device fault state.

erDiagram ATM { string atm_id PK string location string status } CUSTOMER { string customer_id PK string name string email string status } CARD { string card_number PK string customer_id FK date expiry string pin_hash } ACCOUNT { string account_number PK string customer_id FK string type decimal balance decimal available } TRANSACTION { string txn_id PK string atm_id FK string account_number FK string type decimal amount string status datetime created_at } CASH_CASSETTE { string cassette_id PK string atm_id FK int denomination int notes_remaining } AUDIT_LOG { string log_id PK string atm_id FK string txn_id FK string event_type datetime ts string detail } CUSTOMER ||--o| CARD : holds CUSTOMER ||--o{ ACCOUNT : owns ACCOUNT ||--o{ TRANSACTION : "audited by" ATM ||--o{ TRANSACTION : "performed at" ATM ||--o{ CASH_CASSETTE : "stocks" ATM ||--o{ AUDIT_LOG : "writes"
Step 6

Class Diagram

classDiagram class ATM { -ATMState state -Session session -CardReader reader -Keypad keypad -Screen screen -CashDispenser dispenser -DepositSlot depositSlot -Printer printer -BankAdapter bank -AuditLog log +onEvent(Event) void +setState(ATMState) void } class ATMState { <> +handle(ATM, Event) } class IdleState class CardInsertedState class AuthenticatingState class SelectingTxnState class ProcessingState class DispensingState class ReceiptState ATMState <|.. IdleState ATMState <|.. CardInsertedState ATMState <|.. AuthenticatingState ATMState <|.. SelectingTxnState ATMState <|.. ProcessingState ATMState <|.. DispensingState ATMState <|.. ReceiptState class Transaction { <> +execute(ATM, Account) Receipt } class Withdraw class CashDeposit class CheckDeposit class Transfer class BalanceInquiry Transaction <|-- Withdraw Transaction <|-- CashDeposit Transaction <|-- CheckDeposit Transaction <|-- Transfer Transaction <|-- BalanceInquiry class Account { <> } Account <|-- SavingAccount Account <|-- CheckingAccount class CashDispenser { -DenominationHandler chain +dispense(amount) Map } class DenominationHandler { <> +setNext(h) +handle(amount, out) } ATM --> ATMState ATM --> Transaction CashDispenser --> DenominationHandler
Step 7

Design Patterns Used

🚦 State — ATM

The ATM has 8 states (Idle, CardInserted, Authenticating, SelectingTxn, Processing, Dispensing, Receipt, Faulted). Each state handles only the events that make sense for it. Illegal events (e.g., "dispense" while Idle) are silently rejected — no boolean spaghetti.

🎯 Strategy — Transaction

Each transaction type implements execute(ATM, Account). Adding MobileRecharge or BillPay is one new class — no edits to ATM or BankAdapter.

⛓️ Chain of Responsibility — CashDispenser

Denomination handlers are linked: ₹500 → ₹100 → ₹50. Each peels off what it can; the rest flows down. If the chain can't make the exact amount, it rolls back atomically.

🔒 Singleton — ATM

One physical device, one in-memory instance. Devices are wired once at boot.

🛡️ Adapter / Proxy — BankAdapter

Hides ISO-8583, retries, idempotency keys behind authorize/commit/reverse. The ATM doesn't know if the bank is local or 3000 km away.

📝 Template Method — Transaction.execute

The base class defines the skeleton (validate → authorize → device-action → commit → log → receipt). Subclasses fill in the device-action step.

Step 8

Sequence — Withdraw Flow

Raj wants ₹2,300. Watch the 2-phase debit and the dispenser chain — these are the two parts the naive design got wrong.

sequenceDiagram actor Raj participant CR as CardReader participant ATM participant KP as Keypad participant TX as Withdraw participant BA as BankAdapter participant CD as CashDispenser participant PR as Printer participant LOG as AuditLog Raj->>CR: insert card CR->>ATM: cardInsertedEvent ATM->>ATM: state = CardInserted ATM->>KP: prompt for PIN Raj->>KP: enters 4-digit PIN KP->>ATM: pinEnteredEvent ATM->>BA: authenticate(card, pin) BA-->>ATM: ok ATM->>ATM: state = SelectingTxn Raj->>KP: chooses Withdraw, ₹2300 KP->>ATM: txnSelected ATM->>TX: execute(ATM, account) TX->>BA: authorizeDebit(account, 2300, txnId) BA-->>TX: HOLD ok TX->>CD: dispense(2300) CD->>CD: ₹500Handler peels 4 → 2000 left CD->>CD: ₹100Handler peels 3 → 0 left CD-->>TX: {₹500: 4, ₹100: 3} alt cash dispensed ok TX->>BA: commitDebit(txnId) TX->>LOG: write SUCCESS TX->>PR: print receipt else dispenser jam TX->>BA: reverseDebit(txnId) TX->>LOG: write JAM, REVERSED TX->>ATM: state = Faulted end ATM->>CR: eject card
The trick: if the dispenser jams after the bank says HOLD ok, we call reverseDebit. The customer's account is never charged for cash that didn't come out. Without 2-phase, the only "fix" is a dispute call to the bank the next day.
Step 9

State Diagram — ATM

Eight states drive every screen, every prompt, every device call. Hardware events are the arrows.

stateDiagram-v2 [*] --> Idle Idle --> CardInserted : cardInsertedEvent CardInserted --> Authenticating : pinEntered Authenticating --> SelectingTxn : authOk Authenticating --> CardInserted : authFail (attempts < 3) Authenticating --> Faulted : authFail (attempts == 3, retain card) SelectingTxn --> Processing : txnSelected Processing --> Dispensing : needsCash Processing --> Receipt : noCashNeeded Dispensing --> Receipt : dispenseOk Dispensing --> Faulted : dispenseJam (reverse debit) Receipt --> Idle : ejectCard Faulted --> Idle : operatorReset Idle --> Idle : cancelEvent
Step 10

Java Implementation

Java 17+. Showing the parts that demonstrate State, Strategy, and Chain. Production code adds device drivers, persistence, and tests.

Enums.java
public enum TransactionType { BALANCE_INQUIRY, DEPOSIT_CASH, DEPOSIT_CHECK, WITHDRAW, TRANSFER }
public enum TransactionStatus { PENDING, AUTHORIZED, SUCCESS, FAILED, REVERSED }
public enum CustomerStatus { ACTIVE, BLOCKED, COMPROMISED, ARCHIVED }
ATMState — State Pattern
public interface ATMState {
  void handle(ATM atm, Event e);
}

public class IdleState implements ATMState {
  public void handle(ATM atm, Event e) {
    if (e instanceof CardInsertedEvent ev) {
      atm.startSession(ev.card());
      atm.setState(new CardInsertedState());
    }
  }
}

public class AuthenticatingState implements ATMState {
  public void handle(ATM atm, Event e) {
    if (e instanceof PinEnteredEvent ev) {
      boolean ok = atm.getBank().authenticate(atm.getSession().card(), ev.pin());
      if (ok) atm.setState(new SelectingTxnState());
      else if (atm.getSession().incrementPinAttempts() >= 3) {
        atm.getReader().retainCard();
        atm.setState(new FaultedState("too many bad PINs"));
      } else atm.getScreen().show("Wrong PIN, try again");
    }
  }
}
Transaction — Strategy + Template Method
public abstract class Transaction {
  protected String txnId = UUID.randomUUID().toString();
  protected TransactionStatus status = TransactionStatus.PENDING;
  protected BigDecimal amount;

  // Template Method — fixes the order; subclasses fill in deviceAction()
  public final Receipt execute(ATM atm, Account acct) {
    validate(acct);
    atm.getBank().authorize(acct, amount, txnId);
    status = TransactionStatus.AUTHORIZED;
    try {
      DeviceResult r = deviceAction(atm, acct);
      atm.getBank().commit(txnId);
      status = TransactionStatus.SUCCESS;
      atm.getLog().write(this, r);
      return new Receipt(txnId, amount, r);
    } catch (DeviceException ex) {
      atm.getBank().reverse(txnId);
      status = TransactionStatus.REVERSED;
      atm.getLog().writeFault(this, ex);
      throw ex;
    }
  }
  protected abstract void validate(Account a);
  protected abstract DeviceResult deviceAction(ATM atm, Account a);
}

public class Withdraw extends Transaction {
  public Withdraw(BigDecimal amt) { this.amount = amt; }
  protected void validate(Account a) {
    if (a.getAvailable().compareTo(amount) < 0)
      throw new InsufficientFundsException();
  }
  protected DeviceResult deviceAction(ATM atm, Account a) {
    Map<Integer,Integer> bills = atm.getDispenser().dispense(amount.intValueExact());
    return new DeviceResult(bills);
  }
}
CashDispenser — Chain of Responsibility
public abstract class DenominationHandler {
  protected final int denomination;
  protected CashCassette cassette;
  protected DenominationHandler next;

  public DenominationHandler(int d, CashCassette c) { this.denomination = d; this.cassette = c; }
  public DenominationHandler setNext(DenominationHandler n) { this.next = n; return n; }

  public Map<Integer,Integer> handle(int remaining, Map<Integer,Integer> out) {
    int count = Math.min(remaining / denomination, cassette.notesRemaining());
    if (count > 0) {
      out.put(denomination, count);
      remaining -= count * denomination;
    }
    if (remaining == 0) return out;
    if (next == null) throw new CannotDispenseException("no exact change for " + remaining);
    return next.handle(remaining, out);
  }
}

public class CashDispenser {
  private final DenominationHandler chain;
  public CashDispenser(List<CashCassette> cassettes) {
    // build chain in DESCENDING denomination order
    cassettes.sort((a,b) -> b.getDenomination() - a.getDenomination());
    DenominationHandler head = null, prev = null;
    for (CashCassette c : cassettes) {
      DenominationHandler h = new SimpleHandler(c.getDenomination(), c);
      if (head == null) head = h; else prev.setNext(h);
      prev = h;
    }
    this.chain = head;
  }
  public synchronized Map<Integer,Integer> dispense(int amount) {
    return chain.handle(amount, new HashMap<>());
  }
}
BankAdapter — 2-phase debit
public interface BankAdapter {
  boolean authenticate(Card card, String pin);
  void authorize(Account a, BigDecimal amount, String txnId); // puts hold
  void commit(String txnId);                                         // finalize
  void reverse(String txnId);                                        // release hold
  BigDecimal getBalance(Account a);
}
Demo — Withdraw + jam recovery
public class Demo {
  public static void main(String[] a) {
    ATM atm = ATM.getInstance();
    atm.onEvent(new CardInsertedEvent(rajCard));
    atm.onEvent(new PinEnteredEvent("1234"));
    atm.onEvent(new TxnSelectedEvent(new Withdraw(new BigDecimal("2300"))));
    // state machine takes over from here — auth → process → dispense → receipt → eject
  }
}
Step 11

Intuition — How to arrive at this design from scratch

1️⃣ Start from the action

"Customer wants ₹2,300 cash." That's the primary flow. Everything else is supporting infrastructure.

2️⃣ Find nouns & verbs

Nouns: ATM, card, customer, account, dispenser, receipt, log. Verbs: authenticate, authorize, dispense, print, reverse.

3️⃣ Watch state, not data

The data here barely changes — but the state changes constantly: idle, authenticating, dispensing. State pattern jumps out.

4️⃣ Variability lives in transactions

5 transaction types today, 8 next year. Strategy.

5️⃣ Cash dispensing is a sub-problem

"Make ₹2,300 from cassettes" is the coin-change problem. Chain of Responsibility encodes it as denomination handlers.

6️⃣ Network calls fail mid-way

2-phase commit (authorize → commit/reverse) is the only correct answer. Single-phase debits are lawsuits.

7️⃣ Concurrency

One customer at a time per ATM device — but the bank network is shared. Make BankAdapter calls idempotent via the txnId.

8️⃣ Fail loudly & auditably

Every state transition lands in AuditLog. The bank will demand it.

Step 12

Improvements over the Grokking Reference Design

AreaGrokking designThis designWhy it matters
ATM lifecycleNo state — just method callsState Pattern with 8 states + 1 Faulted"Card forgotten in slot", "dispenser jam", "wrong PIN x3" all need lifecycle awareness; method-only design can't represent them
Cash dispenseFields totalFiveDollarBills, totalTwentyDollarBills with no algorithmChain of Responsibility — one handler per denomination, atomic rollback if exact change impossibleReal ATMs run out of small notes constantly. The chain is the right abstraction; the field-counter design doesn't model the problem
Bank debitSingle-step debit2-phase: authorize → commit/reverse, idempotent via txnIdIf the dispenser jams after a single-step debit, the customer's money is gone. 2-phase prevents the entire class of bug
PIN failureReject and retry — unlimitedCounter on Session → 3 attempts → retain cardWithout lockout, a stolen card is brute-forced in seconds
TransactionsSubclasses exist but have no execute() methodTemplate Method execute(): validate → authorize → device → commit → logWithout a real execute, the ATM ends up with a giant switch on TransactionType. Strategy + Template gives polymorphic dispatch
Hardware faultsMentioned only as "internal log"AuditLog is a first-class component; Faulted state is explicitAn interviewer asking about printer-out / paper-out wants to see explicit handling
Concurrency"One customer at a time" — vaguePer-account locks at BankAdapter; ATM-level state machine; idempotent txnId on retryCrucial when the same card is used at two ATMs simultaneously (rare but happens)
BankAdapterDirect method calls on BankAdapter interface — production swaps in ISO-8583 driver, tests swap in fakeTestability + isolation from network protocol churn
Step 13

Extension Points

📱 Mobile recharge

New MobileRecharge extends Transaction. No edits to ATM or BankAdapter — just register the new strategy in the menu.

💱 Multi-currency

Add a CurrencyConverter adapter wrapping BankAdapter. Withdraw still calls authorize; conversion happens beneath.

👁️ Biometric auth

Plug a new AuthenticatingState variant that prompts for fingerprint after PIN. Other states untouched.

Step 14

Trade-offs & Talking Points

DecisionAlternativeWhy this choice
State Pattern (concrete classes)Enum + giant switchEnum-switch grows by 8 cases for every event. Concrete states encapsulate event handling per state. Adding a state = one class
2-phase debitSingle debit + manual reversalManual reversal means the customer waits 24 hours for a dispute to resolve. 2-phase makes "money never actually left" the default
Chain of Responsibility for dispenserGreedy algorithm in one methodGreedy is a one-liner; chain isolates the per-denomination logic so cassette refills, cassette failures, and swap-out are all per-handler concerns
BankAdapter interfaceDirect callsTests need a fake bank. Production needs ISO-8583. Interface is the seam
BigDecimal for moneydouble / int paiseBigDecimal for safety; int-paise is fine but easy to slip up on division
Synchronized dispense()Per-cassette lockOne ATM, one customer at a time — fine-grained locks are theatre
Step 15

Interview Q & A

What if the dispenser jams after the bank has debited the account?
The Bank call is two-phase. authorize(account, amount, txnId) only puts a hold on the funds — the balance is reserved but not yet deducted. Then dispense(). If it succeeds, commit(txnId) finalizes the debit. If it throws, reverse(txnId) releases the hold. The customer's account is never short.
How do you make ₹2,350 if the ATM is out of ₹50 notes?
Chain of Responsibility. ₹500 handler peels 4 → 350 left. ₹100 handler peels 3 → 50 left. ₹50 handler tries — cassette empty. Last handler in chain throws CannotDispenseException. Atomicity is in the chain: nothing is dispensed until the chain returns successfully. The ATM tells the customer "amount unavailable in current denominations, try ₹2,300 or ₹2,400".
Wrong PIN three times — what happens?
Each failed attempt increments session.pinAttempts. On the 3rd, AuthenticatingState calls cardReader.retainCard() (physically swallows the card) and transitions to Faulted. The card is now physically held; the Operator collects it and the customer must visit the bank.
Why State Pattern and not just an enum?
An enum needs a switch in every event handler. With 8 states × 6 event types, that's a 48-row switch. State Pattern puts each state's event handling in its own class — one file per state. Adding a state (e.g., BiometricAuthState) is one new class; the existing states need zero edits.
Two ATMs, same card, same instant — what protects the account?
The ATM-level state machine guarantees one transaction at a time per device. Cross-ATM, the protection lives in BankAdapter.authorize — it grabs an account-level lock and checks available balance. The first authorize wins; the second sees insufficient funds and aborts. Idempotency is via the txnId so a network retry doesn't double-debit.
What if the printer is out of paper mid-transaction?
A real ATM must not reverse a successful withdrawal because of a paper jam. Printer.printReceipt catches the out-of-paper condition, logs a "needs refill" alert to AuditLog, and the transaction completes successfully. The screen shows "receipt unavailable, transaction complete" before ejecting the card.
How do you test this without a real ATM?
Every device is behind an interface (CardReader, Keypad, Screen, CashDispenser, Printer, BankAdapter). In tests, swap in fakes. Drive the state machine with synthetic events; assert on which methods the fakes were called with. The State Pattern + Adapter Pattern are why this works.