vishal maurya

LLD Series: ATM (Low-Level Design)

Overview

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

  1. Insert card -> enter PIN -> choose transaction -> eject card.
  2. Operations: withdraw, deposit, balance enquiry.
  3. PIN verified by the bank; 3 wrong attempts block the card.
  4. Cash dispensed using available denominations (INR 2000/500/200/100), largest first.
  5. Limits: multiple of 100, per-transaction limit (ATM), daily limit (bank).
  6. Every attempt is journaled (SUCCESS / FAILED).
  7. 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

PatternWhereWhy / rejected alternative
StateAtmState + 4 statesBehaviour 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 ResponsibilityDenominationHandlerEach 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").
FacadeAtmSingle API for the keypad/UI; states are internal.
Port/AdapterBankServiceATM does not know whether the bank is a switch, ISO 8583 gateway or in-memory fake.
Singleton (stateless states)XState.INSTANCEStates 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)TransactionCould 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 DenominationHandler with a cap; chain order unchanged.
  • New transaction (PIN change, mini statement, transfer): add a method to AtmState with default reject, implement in AuthenticatedState (or a new PinChangeState). Existing states untouched.
  • New state (MAINTENANCE, SESSION_TIMEOUT): new class; transitions only where entered.
  • Different bank/switch: implement BankService.
  • Cardless / UPI withdrawal: new CardlessAuthState replacing CardInsertedState, same AuthenticatedState.

8. Concurrency and edge cases

  • Machine-level lock: a physical ATM serves one customer; synchronized on the Atm makes the state transitions atomic and also protects CashDispenser (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. debit is atomic at the bank (here synchronized on service; in production a conditional update UPDATE 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

  1. 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.
  2. 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 InsufficientNotes error.
  3. 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.
  4. Network timeout to bank? Idempotent reversal keyed by stan/txnId; ATM retries reversal until acked; never dispense on an unknown outcome.
  5. How to persist / distribute? ATM keeps local journal (SQLite) and inventory. Central side: account, card, txn tables; 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.
  6. Add multi-currency / foreign cards? The BankService becomes 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.
  7. Security? Encrypt PIN at the keypad (PIN block, ISO 9564), mask PAN, tamper detection state, limited retries, no PIN in logs.
  8. 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 new on each call with no reason.
  • Not handling wrong-PIN counting/blocking.

Mini rubric (20 pts)

AreaPoints
Requirements + assumptions3
State pattern implemented correctly (illegal ops blocked)5
Denomination chain (dry-run plan then commit)4
Bank boundary, failure ordering and compensation4
Runnable demo covering PIN block, limits, no-notes2
Extensibility and follow-ups2