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.
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.
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.
Holds the card. Inserts it, types the PIN, picks a transaction, takes the cash & receipt.
Field staff. Refills cash cassettes, paper rolls, removes deposit envelopes. Has a special key-switch.
Remote — pulls totals and remaining-cash reports through a back-office portal.
The silent system actor. Holds the source of truth on accounts. Authenticates and authorizes every debit.
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.
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.
Then reality hits:
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.
The naive code just rejects each attempt. Anyone who steals the card has unlimited tries. No lockout / retain logic.
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.
Customer walked away after the cash. Card sits in the reader, next person grabs it, identity theft. No card retention timeout.
Bank wants to add "mobile-recharge" as a transaction. The monolithic withdraw method has to grow a giant if/else. No transaction abstraction.
Bank reply lost. ATM doesn't know if the debit actually happened. No idempotency / no reversal protocol.
Two concerns are conflated in the monolith:
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.
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.
Each numbered box below earns its place by fixing one of Pass 1's failures. Card colors match the diagram fills.
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.
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.
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.
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".
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".
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.
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.
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.
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.
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.
| Entity | Responsibility | Key Fields |
|---|---|---|
ATM | Singleton — holds devices, current state, current session | String atmId, Address location, ATMState state, Session session |
Session | One customer's interaction (card insert → eject) | Card card, Customer customer, List<Transaction> txns, int pinAttempts |
Card | The customer's debit card | String cardNumber, String customerName, Date expiry, String pinHash |
Customer | The cardholder | String name, String email, CustomerStatus status |
Account | Abstract — holds balance | String accountNumber, BigDecimal balance, BigDecimal available |
SavingAccount / CheckingAccount | Concrete — adds limits / debit-card link | (plus subclass-specific fields) |
Transaction | Abstract Strategy — one customer action | String txnId, TransactionStatus status, BigDecimal amount |
CardReader | Card slot device | boolean cardInside, boolean retained |
CashDispenser | Chain of denomination handlers | List<CashCassette> cassettes |
BankAdapter | Boundary to bank network — 2-phase debit | (stateless) |
Persistence shape: most state lives at the Bank, not the ATM. The ATM only persists its own audit log and device fault state.
ATMThe 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.
TransactionEach transaction type implements execute(ATM, Account). Adding MobileRecharge or BillPay is one new class — no edits to ATM or BankAdapter.
CashDispenserDenomination 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.
ATMOne physical device, one in-memory instance. Devices are wired once at boot.
BankAdapterHides ISO-8583, retries, idempotency keys behind authorize/commit/reverse. The ATM doesn't know if the bank is local or 3000 km away.
Transaction.executeThe base class defines the skeleton (validate → authorize → device-action → commit → log → receipt). Subclasses fill in the device-action step.
Raj wants ₹2,300. Watch the 2-phase debit and the dispenser chain — these are the two parts the naive design got wrong.
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.Eight states drive every screen, every prompt, every device call. Hardware events are the arrows.
Java 17+. Showing the parts that demonstrate State, Strategy, and Chain. Production code adds device drivers, persistence, and tests.
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 }
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"); } } }
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); } }
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<>()); } }
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); }
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 } }
"Customer wants ₹2,300 cash." That's the primary flow. Everything else is supporting infrastructure.
Nouns: ATM, card, customer, account, dispenser, receipt, log. Verbs: authenticate, authorize, dispense, print, reverse.
The data here barely changes — but the state changes constantly: idle, authenticating, dispensing. State pattern jumps out.
5 transaction types today, 8 next year. Strategy.
"Make ₹2,300 from cassettes" is the coin-change problem. Chain of Responsibility encodes it as denomination handlers.
2-phase commit (authorize → commit/reverse) is the only correct answer. Single-phase debits are lawsuits.
One customer at a time per ATM device — but the bank network is shared. Make BankAdapter calls idempotent via the txnId.
Every state transition lands in AuditLog. The bank will demand it.
| Area | Grokking design | This design | Why it matters |
|---|---|---|---|
| ATM lifecycle | No state — just method calls | State 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 dispense | Fields totalFiveDollarBills, totalTwentyDollarBills with no algorithm | Chain of Responsibility — one handler per denomination, atomic rollback if exact change impossible | Real ATMs run out of small notes constantly. The chain is the right abstraction; the field-counter design doesn't model the problem |
| Bank debit | Single-step debit | 2-phase: authorize → commit/reverse, idempotent via txnId | If the dispenser jams after a single-step debit, the customer's money is gone. 2-phase prevents the entire class of bug |
| PIN failure | Reject and retry — unlimited | Counter on Session → 3 attempts → retain card | Without lockout, a stolen card is brute-forced in seconds |
| Transactions | Subclasses exist but have no execute() method | Template Method execute(): validate → authorize → device → commit → log | Without a real execute, the ATM ends up with a giant switch on TransactionType. Strategy + Template gives polymorphic dispatch |
| Hardware faults | Mentioned only as "internal log" | AuditLog is a first-class component; Faulted state is explicit | An interviewer asking about printer-out / paper-out wants to see explicit handling |
| Concurrency | "One customer at a time" — vague | Per-account locks at BankAdapter; ATM-level state machine; idempotent txnId on retry | Crucial when the same card is used at two ATMs simultaneously (rare but happens) |
| BankAdapter | Direct method calls on Bank | Adapter interface — production swaps in ISO-8583 driver, tests swap in fake | Testability + isolation from network protocol churn |
New MobileRecharge extends Transaction. No edits to ATM or BankAdapter — just register the new strategy in the menu.
Add a CurrencyConverter adapter wrapping BankAdapter. Withdraw still calls authorize; conversion happens beneath.
Plug a new AuthenticatingState variant that prompts for fingerprint after PIN. Other states untouched.
| Decision | Alternative | Why this choice |
|---|---|---|
| State Pattern (concrete classes) | Enum + giant switch | Enum-switch grows by 8 cases for every event. Concrete states encapsulate event handling per state. Adding a state = one class |
| 2-phase debit | Single debit + manual reversal | Manual 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 dispenser | Greedy algorithm in one method | Greedy is a one-liner; chain isolates the per-denomination logic so cassette refills, cassette failures, and swap-out are all per-handler concerns |
| BankAdapter interface | Direct calls | Tests need a fake bank. Production needs ISO-8583. Interface is the seam |
| BigDecimal for money | double / int paise | BigDecimal for safety; int-paise is fine but easy to slip up on division |
| Synchronized dispense() | Per-cassette lock | One ATM, one customer at a time — fine-grained locks are theatre |
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.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".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.BiometricAuthState) is one new class; the existing states need zero edits.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.