Design an ATM

Mediumobject-oriented design · design patterns · state pattern · chain of responsibility · command pattern · java⏱ 17 min readAsked at Amazon, Microsoft, Goldman Sachs, JPMorgan, OracleUpdated

Design an automated teller machine with a State-pattern session (card, PIN, transactions), a bank service boundary, Command-style transactions, and a chain-of-responsibility cash dispenser that plans notes before it debits anyone.

Requirements

An ATM question checks three things: can you model a session as a state machine, can you draw a clean boundary between the machine and the bank, and can you handle money and failure without ever losing a dollar?

Functional

  • A customer inserts a card and enters a PIN. After 3 wrong PINs the card is retained.
  • Once authenticated, the customer can:
    • check the balance,
    • withdraw cash (subject to account balance, a daily limit and the notes physically in the machine),
    • deposit cash.
  • The customer can perform several transactions, then eject the card.
  • The cash dispenser holds notes of several denominations (e.g. $100, $50, $20) and must pay any amount it can, using what it actually has.
  • An operator can take the machine out of service to refill cash.

Non-functional

  • Correctness over speed: never debit without dispensing, never dispense without debiting.
  • Security: the ATM never stores or compares PINs itself; the bank verifies them.
  • A joint account may be used from two ATMs at the same time; the bank must keep balances consistent.
  • New transaction types (transfer, mini-statement, PIN change) should plug in easily.

Out of scope (confirm first)

  • Network protocol details (ISO 8583), receipt printer hardware, cheque deposits, multi-currency.
Tip

Start by drawing the line between ATM (hardware + session) and Bank (accounts, PINs, limits). Almost every follow-up question becomes easier once that boundary is explicit.

Core Entities

Entity Responsibility Key fields
Card (record) Identifies the customer's card number, holder
Account Balance and daily withdrawal limit; atomic debit/credit number, balance, dailyLimit, withdrawnToday
BankService The bank boundary: PIN check, balance, debit, credit, reversal interface
InMemoryBank Demo bank; stores salted PIN hashes, never raw PINs accounts, cards, debits
NoteHandler One link in the dispenser chain: one denomination and its note count denomination, count, next
CashDispenser Builds the chain; plans, dispenses and reports cash head
Transaction (abstract) A command the session executes id, amount
BalanceInquiry, Withdrawal, Deposit Concrete transactions -
Receipt (record) Outcome of a transaction txId, type, amount, balanceAfter, status
AtmState Behaviour of the machine in one session phase interface
IdleState, CardInsertedState, AuthenticatedState, OutOfServiceState Concrete states -
ATM Context and facade; holds the current card, account, PIN attempts and state state, bank, dispenser

Class Diagram

classDiagram
  class ATM {
    -AtmState state
    -Card card
    -String account
    -int failedPins
    +insertCard(Card) void
    +enterPin(String) void
    +withdraw(long) Optional
    +deposit(long) Optional
    +checkBalance() Optional
    +ejectCard() void
  }
  class AtmState {
    <<interface>>
    +insertCard(ATM, Card) void
    +enterPin(ATM, String) void
    +perform(ATM, Transaction) Optional
    +ejectCard(ATM) void
  }
  class IdleState
  class CardInsertedState
  class AuthenticatedState
  class OutOfServiceState
  class BankService {
    <<interface>>
    +verifyPin(String, String) boolean
    +debit(String, long, String) DebitResult
    +credit(String, long, String) void
    +reverse(String) void
  }
  class InMemoryBank
  class Account {
    -long balance
    -long dailyLimit
    +debit(long) DebitResult
    +credit(long) void
  }
  class Transaction {
    <<abstract>>
    -String id
    -long amount
    +execute(BankService, CashDispenser, String) Receipt
  }
  class Withdrawal
  class Deposit
  class BalanceInquiry
  class CashDispenser {
    -NoteHandler head
    +plan(long) Optional
    +dispense(Map) void
  }
  class NoteHandler {
    -int denomination
    -int count
    -NoteHandler next
    +plan(long, Map) boolean
    +dispense(Map) void
  }

  AtmState <|.. IdleState
  AtmState <|.. CardInsertedState
  AtmState <|.. AuthenticatedState
  AtmState <|.. OutOfServiceState
  BankService <|.. InMemoryBank
  Transaction <|-- Withdrawal
  Transaction <|-- Deposit
  Transaction <|-- BalanceInquiry
  ATM --> AtmState
  ATM --> BankService
  ATM *-- CashDispenser
  ATM ..> Transaction : creates
  InMemoryBank "1" *-- "many" Account
  CashDispenser --> NoteHandler
  NoteHandler --> NoteHandler : next

Session states:

flowchart LR
  Idle -- "insert card" --> CardInserted
  CardInserted -- "correct PIN" --> Authenticated
  CardInserted -- "wrong PIN, attempts left" --> CardInserted
  CardInserted -- "3rd wrong PIN: retain card" --> Idle
  CardInserted -- "eject" --> Idle
  Authenticated -- "transaction" --> Authenticated
  Authenticated -- "eject" --> Idle
  Idle -- "operator" --> OutOfService
  OutOfService -- "operator" --> Idle

Design Patterns Used

Pattern Where Why it is justified
State AtmState and four states "Enter PIN" means verify in CardInserted, is meaningless in Idle, and must be ignored in Authenticated. Each state answers every input for one phase, so invalid actions are rejected by default
Chain of Responsibility NoteHandler links ($100 → $50 → $20 → ...) Each link pays what it can in its denomination and passes the remainder on. Adding a $10 or $200 cassette means adding one link, with no change to the others
Command Transaction subclasses A transaction is an object with an id that can be executed, logged, audited and reversed. New types (transfer, PIN change) are new classes
Facade ATM Card reader, keypad and screen call a handful of methods; they never see states, the bank or the cassettes
Dependency inversion BankService interface The ATM depends on an abstraction, so tests use an in-memory bank and production uses a network client
Insight

A plain greedy chain (take as many big notes as possible, then pass the rest on) fails when a cassette is low. With one $50 note and plenty of $20s, $60 greedily becomes $50 + $10, and there is no $10, yet 3 × $20 works. Each NoteHandler below tries its maximum count first and backs off one note at a time if the rest of the chain cannot finish, so the chain finds a valid combination whenever one exists.

Plan, debit, dispense. The order of operations in Withdrawal is the heart of the design:

  1. Ask the dispenser for a plan (which notes). This mutates nothing.
  2. Ask the bank to debit the account. The bank checks balance and daily limit atomically.
  3. Dispense the planned notes.
  4. If dispensing fails mechanically, reverse the debit using the transaction id.

Doing it in any other order either debits customers for cash the machine cannot produce or hands out cash the bank later refuses.

Java Implementation

A single Main.java (Java 17+). Amounts are whole dollars because the machine only handles notes; a real bank ledger would use minor units (cents).

java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

enum TransactionType { BALANCE_INQUIRY, WITHDRAWAL, DEPOSIT }

enum DebitResult { OK, INSUFFICIENT_FUNDS, DAILY_LIMIT_EXCEEDED }

record Card(String number, String holder) {}

record Receipt(String txId, TransactionType type, long amount, long balanceAfter, String status) {
    @Override public String toString() {
        return String.format("%s %s $%d: %s (balance $%d)", txId, type, amount, status, balanceAfter);
    }
}

class Account {
    private final String number;
    private final long dailyLimit;
    private long balance;
    private long withdrawnToday;

    Account(String number, long balance, long dailyLimit) {
        this.number = number;
        this.balance = balance;
        this.dailyLimit = dailyLimit;
    }

    String getNumber() { return number; }
    synchronized long getBalance() { return balance; }

    // Check-and-debit is atomic, so two ATMs cannot overdraw a joint account.
    synchronized DebitResult debit(long amount) {
        if (amount > balance) return DebitResult.INSUFFICIENT_FUNDS;
        if (withdrawnToday + amount > dailyLimit) return DebitResult.DAILY_LIMIT_EXCEEDED;
        balance -= amount;
        withdrawnToday += amount;
        return DebitResult.OK;
    }

    synchronized void credit(long amount) { balance += amount; }

    synchronized void undoDebit(long amount) {
        balance += amount;
        withdrawnToday -= amount;
    }
}

interface BankService {
    boolean verifyPin(String cardNumber, String pin);
    Optional<String> accountFor(String cardNumber);
    long balance(String account);
    DebitResult debit(String account, long amount, String txId);
    void credit(String account, long amount, String txId);
    void reverse(String txId);
}

class InMemoryBank implements BankService {
    private record CardRecord(String account, byte[] salt, byte[] pinHash) {}
    private record Debit(String account, long amount) {}

    private final Map<String, Account> accounts = new ConcurrentHashMap<>();
    private final Map<String, CardRecord> cards = new ConcurrentHashMap<>();
    private final Map<String, Debit> debits = new ConcurrentHashMap<>();
    private final SecureRandom random = new SecureRandom();

    void openAccount(String number, long balance, long dailyLimit) {
        accounts.put(number, new Account(number, balance, dailyLimit));
    }

    void issueCard(String cardNumber, String account, String pin) {
        byte[] salt = new byte[16];
        random.nextBytes(salt);
        cards.put(cardNumber, new CardRecord(account, salt, hash(salt, pin)));
    }

    private static byte[] hash(byte[] salt, String pin) {
        try {
            MessageDigest sha = MessageDigest.getInstance("SHA-256");
            sha.update(salt);
            return sha.digest(pin.getBytes(StandardCharsets.UTF_8));
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException(e);
        }
    }

    public boolean verifyPin(String cardNumber, String pin) {
        CardRecord rec = cards.get(cardNumber);
        return rec != null && MessageDigest.isEqual(rec.pinHash(), hash(rec.salt(), pin));
    }

    public Optional<String> accountFor(String cardNumber) {
        return Optional.ofNullable(cards.get(cardNumber)).map(CardRecord::account);
    }

    public long balance(String account) { return accounts.get(account).getBalance(); }

    public DebitResult debit(String account, long amount, String txId) {
        DebitResult result = accounts.get(account).debit(amount);
        if (result == DebitResult.OK) debits.put(txId, new Debit(account, amount));
        return result;
    }

    public void credit(String account, long amount, String txId) { accounts.get(account).credit(amount); }

    public void reverse(String txId) {
        Debit d = debits.remove(txId);                 // remove makes reversal idempotent
        if (d != null) accounts.get(d.account()).undoDebit(d.amount());
    }
}

// One link of the chain: pays what it can in its denomination, passes the rest on.
class NoteHandler {
    private final int denomination;
    private int count;
    private NoteHandler next;

    NoteHandler(int denomination, int count) {
        this.denomination = denomination;
        this.count = count;
    }

    NoteHandler linkTo(NoteHandler nextHandler) {
        this.next = nextHandler;
        return nextHandler;
    }

    boolean plan(long amount, Map<Integer, Integer> notes) {
        long max = Math.min(amount / denomination, count);
        for (long k = max; k >= 0; k--) {               // most notes first, back off if the chain gets stuck
            long rest = amount - k * denomination;
            if (rest == 0 || (next != null && next.plan(rest, notes))) {
                if (k > 0) notes.put(denomination, (int) k);
                return true;
            }
        }
        return false;
    }

    void dispense(Map<Integer, Integer> notes) {
        int k = notes.getOrDefault(denomination, 0);
        if (k > count) throw new IllegalStateException("Cassette " + denomination + " short");
        count -= k;
        if (next != null) next.dispense(notes);
    }

    void describe(Map<Integer, Integer> out) {
        out.put(denomination, count);
        if (next != null) next.describe(out);
    }

    long total() { return (long) denomination * count + (next == null ? 0 : next.total()); }
}

class CashDispenser {
    private final NoteHandler head;

    CashDispenser(Map<Integer, Integer> cassettes) {
        List<Integer> denoms = new ArrayList<>(cassettes.keySet());
        denoms.sort(Comparator.reverseOrder());
        head = new NoteHandler(denoms.get(0), cassettes.get(denoms.get(0)));
        NoteHandler tail = head;
        for (int i = 1; i < denoms.size(); i++) tail = tail.linkTo(new NoteHandler(denoms.get(i), cassettes.get(denoms.get(i))));
    }

    synchronized Optional<Map<Integer, Integer>> plan(long amount) {
        Map<Integer, Integer> notes = new TreeMap<>(Comparator.reverseOrder());
        return amount > 0 && head.plan(amount, notes) ? Optional.of(notes) : Optional.empty();
    }

    synchronized void dispense(Map<Integer, Integer> notes) { head.dispense(notes); }
    synchronized long total() { return head.total(); }

    @Override public synchronized String toString() {
        Map<Integer, Integer> out = new TreeMap<>(Comparator.reverseOrder());
        head.describe(out);
        return out + " = $" + head.total();
    }
}

abstract class Transaction {
    protected final String id;
    protected final long amount;

    Transaction(String id, long amount) {
        this.id = id;
        this.amount = amount;
    }

    abstract TransactionType type();
    abstract Receipt execute(BankService bank, CashDispenser dispenser, String account);

    protected Receipt result(BankService bank, String account, String status) {
        return new Receipt(id, type(), amount, bank.balance(account), status);
    }
}

class BalanceInquiry extends Transaction {
    BalanceInquiry(String id) { super(id, 0); }
    TransactionType type() { return TransactionType.BALANCE_INQUIRY; }
    Receipt execute(BankService bank, CashDispenser dispenser, String account) { return result(bank, account, "OK"); }
}

class Withdrawal extends Transaction {
    Withdrawal(String id, long amount) { super(id, amount); }
    TransactionType type() { return TransactionType.WITHDRAWAL; }

    Receipt execute(BankService bank, CashDispenser dispenser, String account) {
        Optional<Map<Integer, Integer>> notes = dispenser.plan(amount);          // 1. plan
        if (notes.isEmpty()) return result(bank, account, "DECLINED: machine cannot dispense this amount");
        DebitResult debit = bank.debit(account, amount, id);                      // 2. debit
        if (debit != DebitResult.OK) return result(bank, account, "DECLINED: " + debit);
        try {
            dispenser.dispense(notes.get());                                      // 3. dispense
        } catch (IllegalStateException hardwareFault) {
            bank.reverse(id);                                                     // 4. compensate
            return result(bank, account, "FAILED: dispense error, debit reversed");
        }
        return result(bank, account, "OK notes " + notes.get());
    }
}

class Deposit extends Transaction {
    Deposit(String id, long amount) { super(id, amount); }
    TransactionType type() { return TransactionType.DEPOSIT; }

    Receipt execute(BankService bank, CashDispenser dispenser, String account) {
        if (amount <= 0) return result(bank, account, "DECLINED: invalid amount");
        bank.credit(account, amount, id);
        return result(bank, account, "OK");
    }
}

interface AtmState {
    String label();
    default void insertCard(ATM atm, Card card) { atm.show("A card is already in use"); }
    default void enterPin(ATM atm, String pin) { atm.show("Insert a card first"); }
    default Optional<Receipt> perform(ATM atm, Transaction tx) { atm.show("Insert card and enter PIN first"); return Optional.empty(); }
    default void ejectCard(ATM atm) { atm.show("No card to eject"); }
}

class IdleState implements AtmState {
    public String label() { return "IDLE"; }

    public void insertCard(ATM atm, Card card) {
        atm.startSession(card);
        atm.setState(new CardInsertedState());
        atm.show("Hello " + card.holder() + ", enter PIN");
    }
}

class CardInsertedState implements AtmState {
    static final int MAX_ATTEMPTS = 3;

    public String label() { return "CARD_INSERTED"; }

    public void enterPin(ATM atm, String pin) {
        String cardNumber = atm.getCard().number();
        Optional<String> account = atm.getBank().accountFor(cardNumber);
        if (account.isPresent() && atm.getBank().verifyPin(cardNumber, pin)) {
            atm.authenticate(account.get());
            atm.setState(new AuthenticatedState());
            atm.show("PIN accepted");
            return;
        }
        int failures = atm.recordFailedPin();
        if (failures >= MAX_ATTEMPTS) {
            atm.retainCard();
            atm.setState(new IdleState());
            atm.show("Too many wrong PINs, card retained. Contact your bank");
        } else {
            atm.show("Wrong PIN, " + (MAX_ATTEMPTS - failures) + " attempt(s) left");
        }
    }

    public void ejectCard(ATM atm) {
        atm.endSession();
        atm.setState(new IdleState());
        atm.show("Card returned");
    }
}

class AuthenticatedState implements AtmState {
    public String label() { return "AUTHENTICATED"; }

    public void enterPin(ATM atm, String pin) { atm.show("Already authenticated"); }

    public Optional<Receipt> perform(ATM atm, Transaction tx) {
        Receipt receipt = tx.execute(atm.getBank(), atm.getDispenser(), atm.getAccount());
        atm.show(receipt.toString());
        return Optional.of(receipt);
    }

    public void ejectCard(ATM atm) {
        atm.endSession();
        atm.setState(atm.getDispenser().total() == 0 ? new OutOfServiceState() : new IdleState());
        atm.show("Card returned, goodbye");
    }
}

class OutOfServiceState implements AtmState {
    public String label() { return "OUT_OF_SERVICE"; }
    public void insertCard(ATM atm, Card card) { atm.show("Out of service, card returned"); }
}

class ATM {
    private final String atmId;
    private final BankService bank;
    private final CashDispenser dispenser;
    private final AtomicLong txSequence = new AtomicLong();
    private final List<Card> retainedCards = new ArrayList<>();
    private AtmState state = new IdleState();
    private Card card;
    private String account;
    private int failedPins;

    ATM(String atmId, BankService bank, CashDispenser dispenser) {
        this.atmId = atmId;
        this.bank = bank;
        this.dispenser = dispenser;
    }

    // Customer-facing API
    synchronized void insertCard(Card c) { state.insertCard(this, c); }
    synchronized void enterPin(String pin) { state.enterPin(this, pin); }
    synchronized Optional<Receipt> checkBalance() { return state.perform(this, new BalanceInquiry(nextTxId())); }
    synchronized Optional<Receipt> withdraw(long amount) { return state.perform(this, new Withdrawal(nextTxId(), amount)); }
    synchronized Optional<Receipt> deposit(long amount) { return state.perform(this, new Deposit(nextTxId(), amount)); }
    synchronized void ejectCard() { state.ejectCard(this); }

    // Operator API
    synchronized void setOutOfService(boolean outOfService) {
        if (card != null) throw new IllegalStateException("Session in progress");
        state = outOfService ? new OutOfServiceState() : new IdleState();
        show("Operator set state " + state.label());
    }

    // Helpers for states
    void startSession(Card c) { card = c; failedPins = 0; }
    void authenticate(String acct) { account = acct; }
    int recordFailedPin() { return ++failedPins; }
    void retainCard() { retainedCards.add(card); endSession(); }
    void endSession() { card = null; account = null; failedPins = 0; }
    void setState(AtmState next) { state = next; }
    Card getCard() { return card; }
    String getAccount() { return account; }
    BankService getBank() { return bank; }
    CashDispenser getDispenser() { return dispenser; }
    List<Card> getRetainedCards() { return List.copyOf(retainedCards); }
    String stateLabel() { return state.label(); }
    private String nextTxId() { return atmId + "-" + txSequence.incrementAndGet(); }
    void show(String message) { System.out.println("  [screen] " + message); }
}

public class Main {
    public static void main(String[] args) {
        InMemoryBank bank = new InMemoryBank();
        bank.openAccount("ACC-1", 300, 1000);
        bank.openAccount("ACC-2", 1000, 280);
        bank.issueCard("5500-01", "ACC-1", "9999");
        bank.issueCard("4111-02", "ACC-2", "1234");
        Card asha = new Card("5500-01", "Asha");
        Card ben = new Card("4111-02", "Ben");

        Map<Integer, Integer> cassettes = new HashMap<>();
        cassettes.put(100, 2);
        cassettes.put(50, 1);
        cassettes.put(20, 5);
        ATM atm = new ATM("ATM7", bank, new CashDispenser(cassettes));
        System.out.println("Cash loaded: " + atm.getDispenser());

        System.out.println("1) Withdraw without a card");
        atm.withdraw(20);

        System.out.println("2) Asha: two wrong PINs, then the right one");
        atm.insertCard(asha);
        atm.enterPin("1111");
        atm.enterPin("0000");
        atm.enterPin("9999");

        System.out.println("3) Asha's transactions");
        atm.checkBalance();
        atm.withdraw(60);
        atm.withdraw(250);
        atm.withdraw(75);
        atm.deposit(100);
        atm.ejectCard();
        System.out.println("  cash left: " + atm.getDispenser());

        System.out.println("4) Ben: amounts limited by cassettes and the daily limit");
        atm.insertCard(ben);
        atm.enterPin("1234");
        atm.withdraw(300);
        atm.withdraw(250);
        atm.withdraw(40);
        atm.ejectCard();
        System.out.println("  cash left: " + atm.getDispenser());

        System.out.println("5) Three wrong PINs retain the card");
        atm.insertCard(asha);
        for (String pin : List.of("1", "2", "3")) atm.enterPin(pin);
        System.out.println("  retained: " + atm.getRetainedCards());

        System.out.println("6) Operator maintenance");
        atm.setOutOfService(true);
        atm.insertCard(ben);
        System.out.println("Final state " + atm.stateLabel() + ", ACC-1 $" + bank.balance("ACC-1") + ", ACC-2 $" + bank.balance("ACC-2"));
    }
}

Demo output

text
Cash loaded: {100=2, 50=1, 20=5} = $350
1) Withdraw without a card
  [screen] Insert card and enter PIN first
2) Asha: two wrong PINs, then the right one
  [screen] Hello Asha, enter PIN
  [screen] Wrong PIN, 2 attempt(s) left
  [screen] Wrong PIN, 1 attempt(s) left
  [screen] PIN accepted
3) Asha's transactions
  [screen] ATM7-2 BALANCE_INQUIRY $0: OK (balance $300)
  [screen] ATM7-3 WITHDRAWAL $60: OK notes {20=3} (balance $240)
  [screen] ATM7-4 WITHDRAWAL $250: DECLINED: INSUFFICIENT_FUNDS (balance $240)
  [screen] ATM7-5 WITHDRAWAL $75: DECLINED: machine cannot dispense this amount (balance $240)
  [screen] ATM7-6 DEPOSIT $100: OK (balance $340)
  [screen] Card returned, goodbye
  cash left: {100=2, 50=1, 20=2} = $290
4) Ben: amounts limited by cassettes and the daily limit
  [screen] Hello Ben, enter PIN
  [screen] PIN accepted
  [screen] ATM7-7 WITHDRAWAL $300: DECLINED: machine cannot dispense this amount (balance $1000)
  [screen] ATM7-8 WITHDRAWAL $250: OK notes {100=2, 50=1} (balance $750)
  [screen] ATM7-9 WITHDRAWAL $40: DECLINED: DAILY_LIMIT_EXCEEDED (balance $750)
  [screen] Card returned, goodbye
  cash left: {100=0, 50=0, 20=2} = $40
5) Three wrong PINs retain the card
  [screen] Hello Asha, enter PIN
  [screen] Wrong PIN, 2 attempt(s) left
  [screen] Wrong PIN, 1 attempt(s) left
  [screen] Too many wrong PINs, card retained. Contact your bank
  retained: [Card[number=5500-01, holder=Asha]]
6) Operator maintenance
  [screen] Operator set state OUT_OF_SERVICE
  [screen] Out of service, card returned
Final state OUT_OF_SERVICE, ACC-1 $340, ACC-2 $750

Walkthrough:

  • Step 1: the IdleState default rejects a transaction. Invalid actions need no special code; the interface defaults handle them.
  • Step 2: wrong PINs keep the machine in CardInsertedState and count down attempts. The ATM only asks the bank "is this PIN right?"; it never sees the stored hash.
  • $60 is paid as 3 × $20. The $50 link first tried one $50, found the $20 link could not pay the remaining $10, and backed off.
  • $250 for Asha is dispensable (2 × $100 + $50) but declined by the bank for insufficient funds. Because the plan step mutates nothing, no cassette counts change.
  • $75 cannot be built from these notes, so it is declined before the bank is even contacted.
  • Ben: $300 exceeds the cash in the machine, $250 succeeds, and $40 would push him past his $280 daily limit.
  • Step 5: the third wrong PIN retains the card and returns the machine to Idle. Note the transaction ids skip ATM7-1: the rejected request in step 1 still consumed an id, which is harmless and keeps id generation lock-free.

Concurrency Considerations

Within one ATM, only one customer is served at a time, but hardware events (card reader, keypad, cancel button, operator panel) can still arrive on different threads. Across ATMs, many machines hit the same accounts.

  • ATM session: one lock. Every public ATM method is synchronized, so state transitions and session fields (card, account, failedPins) change together. The operator cannot switch the machine off in the middle of a session.
  • Account: atomic check-and-debit. Account.debit checks balance and daily limit and subtracts in one synchronized block. Two ATMs withdrawing from a joint account cannot both pass the balance check. In a real bank this is a single database transaction or a conditional update (UPDATE account SET balance = balance - ? WHERE id = ? AND balance >= ?).
  • Idempotent reversal. reverse removes the debit record before refunding, so a retried reversal (e.g. after a network timeout) cannot refund twice. Real systems key every message by transaction id for the same reason.
  • Dispenser: plan and dispense under one lock. The dispenser is only touched by its own ATM, but keeping plan and dispense synchronized documents that the counts must not change between them.
Warning

The most dangerous failure is a network timeout after the debit request: the ATM does not know whether the bank debited the account. The safe rule is "if unsure, do not dispense, then send a reversal". Because reversals are idempotent per transaction id, sending one for a debit that never happened is harmless.

Extensions

Requirement Change
Transfers, mini-statements, PIN change New Transaction subclasses; AuthenticatedState executes them unchanged
New note denominations Add a cassette; CashDispenser links one more NoteHandler
Prefer fewer notes or preserve scarce notes Swap the order or the backing-off rule in the chain, or add a NoteSelectionStrategy
Session timeout AuthenticatedState records last activity; a timer calls ejectCard (and retains the card if it is not taken)
Receipts and audit log Observer: ATM publishes each Receipt; printer and audit journal subscribe
Low-cash alerts CashDispenser publishes an event when a cassette drops below a threshold
Cardless / mobile withdrawal A new OneTimeCodeState that authenticates via a code from the banking app
Deposits with note validation DepositState that counts and validates notes before crediting; suspicious notes are held in a separate bin

Interview Tips

  • Draw the ATM/bank boundary first and keep PIN verification on the bank side.
  • Walk through the session state machine before writing code; then each state class is one node of the diagram.
  • Explain "plan, debit, dispense, reverse on failure" and why the order matters.
  • Point out that a greedy dispenser can fail with low cassettes, and show the backing-off chain.
  • Mention daily limits and atomic debit for joint accounts; interviewers often ask about two ATMs at once.
  • Keep transactions as commands with ids; it makes logging, auditing and reversals natural.

References

My notes saved in this browser