Hirestack
Hirestack
Chapter 7

Problem 3: Design an ATM

📖 6 min read · 10 sections · May 2026
Asked at: Amazon Goldman Sachs JPMorgan

The setup

"Design an ATM (Automated Teller Machine). Users can insert a card, enter PIN, withdraw cash, deposit cash, check balance, transfer between accounts. Multi-currency optional. The ATM communicates with a bank server for authorization."

This problem combines state machines (ATM has clear UI states), the Command pattern (transactions), and external system integration (bank server). The interviewer is testing whether you can model a multi-step transactional flow with proper failure handling.

Clarifying questions

QuestionStory answer
Single-currency or multi?Single (INR) for v1.
What's the cash inventory model?ATM has fixed slots of ₹100, ₹500, ₹2000 notes. Dispense based on availability.
Deposit verification?Assume the ATM can verify deposited cash on insertion (real ATMs do this).
Transaction limits?₹50,000 per day per card.
What happens on network failure mid-transaction?Rollback the local state; user retries.
Audit trail?Yes — every transaction logged with timestamp, card, amount, result.

Core entities

EntityResponsibility
ATMThe main facade; orchestrates state transitions and external calls
ATMStateInterface for each UI state (IdleState, AuthenticatingState, etc.)
CardRepresents the user's card; has card number, account info
CashDispenserManages the ATM's cash inventory; dispenses requested amounts
BankServiceExternal interface to the bank; verify PIN, debit account, credit account
TransactionAbstract base for WithdrawTransaction, DepositTransaction, etc. (Command pattern)
TransactionLogAudit log of all transactions

Design Decision 1: ATM as a State Machine

stateDiagram-v2
    [*] --> Idle
    Idle --> CardInserted: card swiped
    CardInserted --> AuthenticatingPIN: PIN entered
    AuthenticatingPIN --> Authenticated: PIN ok
    AuthenticatingPIN --> CardInserted: PIN wrong (retry)
    AuthenticatingPIN --> CardEjected: 3 failures
    Authenticated --> SelectingTransaction
    SelectingTransaction --> Withdrawing: withdraw
    SelectingTransaction --> Depositing: deposit
    SelectingTransaction --> CheckingBalance: balance
    Withdrawing --> DispensingCash: amount valid
    DispensingCash --> SelectingTransaction: another transaction
    DispensingCash --> CardEjected: exit
    Depositing --> SelectingTransaction
    CheckingBalance --> SelectingTransaction
    CardEjected --> Idle

The ATM has clearly defined UI states:

States:
  IDLE              — waiting for card
  CARD_INSERTED     — card read, prompt for PIN
  PIN_ENTERED       — verifying with bank
  AUTHENTICATED     — main menu (withdraw/deposit/balance/etc.)
  TRANSACTION_IN_PROGRESS — executing chosen transaction
  PRINTING_RECEIPT  — final receipt
  EJECTING_CARD     — returning card

Transitions:
  IDLE + card_inserted → CARD_INSERTED
  CARD_INSERTED + pin_entered → PIN_ENTERED
  PIN_ENTERED + bank_ok → AUTHENTICATED
  PIN_ENTERED + bank_fail → EJECTING_CARD
  AUTHENTICATED + select_transaction → TRANSACTION_IN_PROGRESS
  TRANSACTION_IN_PROGRESS + complete → PRINTING_RECEIPT
  ...

Implement via State pattern:

interface ATMState {
  void insertCard(ATM atm, Card card);
  void enterPin(ATM atm, String pin);
  void selectTransaction(ATM atm, TransactionType type);
  void executeTransaction(ATM atm, Transaction tx);
  void cancel(ATM atm);
}

class IdleState implements ATMState {
  public void insertCard(ATM atm, Card card) {
    atm.setCurrentCard(card);
    atm.transitionTo(new CardInsertedState());
  }
  
  public void enterPin(ATM atm, String pin) {
    throw new IllegalStateException("No card inserted");
  }
  // Other methods: throw or noop
}

class CardInsertedState implements ATMState {
  public void enterPin(ATM atm, String pin) {
    atm.transitionTo(new PinVerificationState());
    boolean ok = atm.getBankService().verifyPin(atm.getCurrentCard(), pin);
    if (ok) atm.transitionTo(new AuthenticatedState());
    else atm.transitionTo(new EjectingCardState());
  }
  // Other methods invalid in this state
}

class AuthenticatedState implements ATMState {
  public void selectTransaction(ATM atm, TransactionType type) {
    Transaction tx = TransactionFactory.create(type, atm);
    atm.transitionTo(new TransactionInProgressState(tx));
  }
}

State pattern keeps each state's allowed operations local. An attempt to do something invalid (enter PIN when no card inserted) throws an exception or is a no-op — caller doesn't need to check the state externally.

Design Decision 2: Transactions as Command pattern

Each transaction type (withdraw, deposit, transfer, balance check) has different parameters and logic but a common shape: validate, execute, log, produce result.

abstract class Transaction {
  protected Card card;
  protected TransactionType type;
  protected Instant startedAt;
  protected TransactionResult result;
  
  public abstract void execute(ATM atm);
  public abstract void rollback(ATM atm);  // for failure recovery
  public TransactionResult getResult() { return result; }
}

class WithdrawTransaction extends Transaction {
  private BigDecimal amount;
  
  public void execute(ATM atm) {
    // Step 1: check bank balance / authorization
    boolean authorized = atm.getBankService().authorize(card, amount);
    if (!authorized) {
      result = TransactionResult.failed("Insufficient funds");
      return;
    }
    // Step 2: debit account (atomically with subsequent dispense)
    DebitResult debit = atm.getBankService().debit(card, amount);
    if (!debit.isSuccess()) {
      result = TransactionResult.failed("Debit failed");
      return;
    }
    // Step 3: dispense cash
    try {
      atm.getCashDispenser().dispense(amount);
      result = TransactionResult.success(amount, debit.transactionId);
    } catch (DispenserException e) {
      // Critical: account debited but cash not dispensed. Must reverse.
      atm.getBankService().reverseDebit(card, amount, debit.transactionId);
      result = TransactionResult.failed("Dispenser malfunction");
    }
  }
  
  public void rollback(ATM atm) {
    if (result != null && result.isSuccess()) {
      atm.getBankService().reverseDebit(card, amount, result.transactionId);
    }
  }
}

class DepositTransaction extends Transaction { ... }
class BalanceInquiryTransaction extends Transaction { ... }
class TransferTransaction extends Transaction { ... }

Why Command pattern: encapsulates the "what" and "how" of each transaction. Caller (the AuthenticatedState) doesn't need to know withdrawal differs from deposit; it just calls execute(). New transaction types are new classes — Open-Closed.

Design Decision 3: Cash dispenser

The dispenser has notes of multiple denominations. To dispense ₹1,300 from {2000, 500, 100}: must figure out a valid combination. Greedy works for these denominations (each is a multiple of smaller ones).

class CashDispenser {
  private Map inventory;  // notes available
  
  public synchronized void dispense(BigDecimal amount) throws DispenserException {
    Map needed = computeDispensingPlan(amount);
    if (needed == null) throw new DispenserException("Cannot dispense " + amount);
    // Atomically decrement inventory
    for (var entry : needed.entrySet()) {
      inventory.merge(entry.getKey(), -entry.getValue(), Integer::sum);
    }
    physicallyDispense(needed);
  }
  
  private Map computeDispensingPlan(BigDecimal amount) {
    // Greedy: largest denomination first
    Map plan = new HashMap<>();
    BigDecimal remaining = amount;
    for (Denomination d : Denomination.descendingOrder()) {
      int available = inventory.getOrDefault(d, 0);
      int needed = remaining.divide(d.value, RoundingMode.DOWN).intValue();
      int taken = Math.min(needed, available);
      plan.put(d, taken);
      remaining = remaining.subtract(d.value.multiply(BigDecimal.valueOf(taken)));
    }
    if (remaining.signum() > 0) return null;  // can't fulfill
    return plan;
  }
}

Cross-examination round 1: network failure

Interviewer: Withdrawal: bank says "debit success" but the connection drops before the ATM dispenses cash. What do you do?
This is the classic "external system inconsistency" problem. Options:
  1. If bank debit succeeded and dispense failed: reverse the debit (the catch block in WithdrawTransaction above). Risk: the reversal call itself might fail.
  2. Persistent transaction log: record every state transition. On startup, ATM detects "in-flight transaction without final state" and triggers reconciliation with bank.
  3. Idempotency keys: every operation has a unique ID. ATM can query bank "what's the status of transaction T?" The bank should respond authoritatively. Reconcile based on truth.
Production systems combine all three. Money correctness is non-negotiable.

Cross-examination round 2: concurrent access

Interviewer: Can two ATMs of the same bank withdraw from the same account simultaneously? Race condition?
The bank server is the source of truth. Each withdrawal goes through the bank's debit endpoint. If two ATMs simultaneously try to withdraw ₹5,000 each from an account with ₹6,000: the bank server must serialize. Common approach: optimistic concurrency on the account row (version-based update); or pessimistic lock via SELECT FOR UPDATE. One succeeds; the other is told "insufficient funds." The ATMs don't need to coordinate with each other — they each trust the bank's response.

Cross-examination round 3: PIN security

Interviewer: Where does PIN verification happen? On the ATM or the bank?
Always the bank. The ATM transmits the encrypted PIN (encrypted with the bank's public key or via secure channel). The bank verifies. Storing PINs on the ATM or comparing locally would be a security disaster. The ATM is a thin client for this purpose — it's a UI + hardware front-end to the bank's authoritative ledger.

Takeaway

The ATM is the canonical example of: State pattern (UI flow) + Command pattern (transactions) + external-system integration (bank). Each piece is solved by a well-known pattern. The interview test is whether you can recognise each, apply correctly, and reason about failure modes (the bank/network can fail; cash dispense can fail; accidents happen).

The deeper insight: money correctness requires idempotency and reconciliation. Whenever you cross a boundary into an external system (bank), assume the worst can happen mid-operation. Idempotency keys, persistent transaction logs, and reconciliation processes are the safety net.