Back to LLD Problems: Parking Lot, Elevator, Chess, etc.
Q9Question 9 of 12

Design an ATM Machine

Key Concepts
  • The ATM is a state machine moving through IDLE, CARD_INSERTED, AUTHENTICATED, TRANSACTION, and DISPENSING states.
  • Authentication validates the PIN; after a few failed attempts the card is retained for security.
  • Transaction types are modelled polymorphically: WithdrawTransaction, DepositTransaction, BalanceInquiry, each knowing how to execute.
  • Withdrawal enforces two independent checks: sufficient account balance and remaining daily limit.
  • The CashDispenser works in available denominations using BigDecimal and fails cleanly if it cannot make the exact amount.
  • Atomicity between debiting the account and dispensing cash is the single most important correctness concern.
  • The debit-first-then-dispense strategy reverses the debit immediately if the physical dispense fails.

Definition

An ATM is a state machine that authenticates, enforces limits, dispenses cash, and keeps the account in sync.
  • ATM – the terminal running as a state machine: IDLE, CARD_INSERTED, AUTHENTICATED, TRANSACTION, DISPENSING.
  • Card – carries the card number and is linked to an Account.
  • Account – holds a balance (BigDecimal), supports debit and credit, tracks daily withdrawal usage.
  • Transaction – abstract base with polymorphic implementations: WithdrawTransaction, DepositTransaction, BalanceInquiry.
  • CashDispenser – dispenses cash in available denominations, fails cleanly if it cannot make the exact amount.
  • PIN validation – authenticates the cardholder; retains the card after a configured number of failed attempts.

The ATM is National Bank of India's tireless teller in a box. Rajesh inserts his card, enters his PIN (identity check), chooses withdraw five thousand, the machine confirms his account has the money and he has not exceeded his daily limit, counts out the notes, debits his account, and prints a receipt. Every step follows a strict order, like the state machine in a vending machine but for cash.

Understanding the Concepts

What are the core entities?

The core entities are ATM, Card, Account, Transaction (abstract), concrete transaction types, CashDispenser, and ReceiptPrinter.

JAVA
package com.bank.atm.model;

public enum ATMState {
    IDLE, CARD_INSERTED, AUTHENTICATED, TRANSACTION, DISPENSING
}
JAVA
package com.bank.atm.model;

import java.math.BigDecimal;

public class Account {
    private final String accountId;
    private BigDecimal balance;
    private BigDecimal dailyWithdrawn;
    private final BigDecimal dailyLimit;

    public Account(String accountId, BigDecimal balance,
                   BigDecimal dailyLimit) {
        this.accountId = accountId;
        this.balance = balance;
        this.dailyLimit = dailyLimit;
        this.dailyWithdrawn = BigDecimal.ZERO;
    }

    public void debit(BigDecimal amount) {
        if (balance.compareTo(amount) < 0) {
            throw new IllegalStateException("Insufficient balance");
        }
        BigDecimal remaining = dailyLimit.subtract(dailyWithdrawn);
        if (remaining.compareTo(amount) < 0) {
            throw new IllegalStateException(
                    "Daily limit exceeded. Remaining: " + remaining);
        }
        balance = balance.subtract(amount);
        dailyWithdrawn = dailyWithdrawn.add(amount);
    }

    public void credit(BigDecimal amount) {
        balance = balance.add(amount);
    }

    public BigDecimal getBalance() { return balance; }
    public String getAccountId() { return accountId; }
}

How does the ATM state machine work?

The ATM moves through states: IDLE → CARD_INSERTED → AUTHENTICATED → TRANSACTION → DISPENSING → IDLE.

JAVA
package com.bank.atm.service;

import com.bank.atm.model.*;
import java.math.BigDecimal;

public class ATMService {
    private ATMState state;
    private Card currentCard;
    private int pinAttempts;
    private static final int MAX_PIN_ATTEMPTS = 3;

    public ATMService() {
        this.state = ATMState.IDLE;
        this.pinAttempts = 0;
    }

    public void insertCard(Card card) {
        if (state != ATMState.IDLE) {
            System.out.println("ATM is busy");
            return;
        }
        this.currentCard = card;
        this.state = ATMState.CARD_INSERTED;
        this.pinAttempts = 0;
        System.out.println("Card inserted: " + card.getCardNumber());
    }

    public boolean enterPin(String pin) {
        if (state != ATMState.CARD_INSERTED) {
            System.out.println("Insert card first");
            return false;
        }
        if (currentCard.validatePin(pin)) {
            state = ATMState.AUTHENTICATED;
            System.out.println("PIN verified");
            return true;
        }
        pinAttempts++;
        if (pinAttempts >= MAX_PIN_ATTEMPTS) {
            System.out.println("Card retained after "
                    + MAX_PIN_ATTEMPTS + " failed attempts");
            retainCard();
            return false;
        }
        System.out.println("Wrong PIN. Attempts left: "
                + (MAX_PIN_ATTEMPTS - pinAttempts));
        return false;
    }

    private void retainCard() {
        currentCard = null;
        state = ATMState.IDLE;
    }
}

How are transactions modelled polymorphically?

Transaction types are modelled as separate classes, each knowing how to execute against the account.

JAVA
package com.bank.atm.model;

public abstract class Transaction {
    protected final Account account;

    protected Transaction(Account account) {
        this.account = account;
    }

    public abstract void execute();
}
JAVA
package com.bank.atm.model;

import java.math.BigDecimal;

public class WithdrawTransaction extends Transaction {
    private final BigDecimal amount;
    private final CashDispenser dispenser;

    public WithdrawTransaction(Account account, BigDecimal amount,
                                CashDispenser dispenser) {
        super(account);
        this.amount = amount;
        this.dispenser = dispenser;
    }

    @Override
    public void execute() {
        // Debit first
        account.debit(amount);
        try {
            // Then dispense
            dispenser.dispense(amount);
            System.out.println("Withdrawn " + amount
                    + " from " + account.getAccountId());
        } catch (Exception e) {
            // Reverse debit if dispense fails
            account.credit(amount);
            System.out.println("Dispense failed, debit reversed");
            throw e;
        }
    }
}
JAVA
package com.bank.atm.model;

public class BalanceInquiry extends Transaction {
    public BalanceInquiry(Account account) {
        super(account);
    }

    @Override
    public void execute() {
        System.out.println("Balance: " + account.getBalance());
    }
}

How does the CashDispenser work?

The dispenser works in available denominations and fails cleanly if it cannot make the exact amount. All money is BigDecimal.

JAVA
package com.bank.atm.model;

import java.math.BigDecimal;
import java.util.*;

public class CashDispenser {
    private final TreeMap<BigDecimal, Integer> notes;

    public CashDispenser(Map<BigDecimal, Integer> notes) {
        this.notes = new TreeMap<>(Collections.reverseOrder());
        this.notes.putAll(notes);
    }

    public void dispense(BigDecimal amount) {
        BigDecimal remaining = amount;
        Map<BigDecimal, Integer> dispensed = new LinkedHashMap<>();

        for (var entry : notes.entrySet()) {
            BigDecimal denomination = entry.getKey();
            int available = entry.getValue();
            int needed = remaining.divideToIntegralValue(
                    denomination).intValue();
            int use = Math.min(needed, available);
            if (use > 0) {
                dispensed.put(denomination, use);
                remaining = remaining.subtract(
                        denomination.multiply(
                                BigDecimal.valueOf(use)));
            }
        }

        if (remaining.compareTo(BigDecimal.ZERO) != 0) {
            throw new IllegalStateException(
                    "Cannot dispense exact amount " + amount);
        }

        // Commit: reduce note inventory
        dispensed.forEach((denom, count) ->
                notes.merge(denom, -count, Integer::sum));
        System.out.println("Dispensed: " + dispensed);
    }
}

Why is debit-dispense atomicity critical?

The machine must never debit without dispensing (cheats the customer) nor dispense without debiting (cheats the bank). The strategy is: debit first, then dispense. If the physical dispense fails, immediately reverse the debit.

JAVA
account.debit(amount);
try {
    dispenser.dispense(amount);
} catch (Exception e) {
    account.credit(amount); // reverse
    throw e;
}

Real ATMs achieve this with a two-phase protocol against the host system.

How does PIN validation enforce security?

After a configured number of failed PIN attempts (typically 3), the card is retained. This prevents brute-force attacks.

What about the receipt printer?

The receipt printer is a simple collaborator that receives transaction details and formats a receipt.

JAVA
package com.bank.atm.model;

import java.math.BigDecimal;
import java.time.LocalDateTime;

public class ReceiptPrinter {
    public void print(String accountId, String type,
                      BigDecimal amount, BigDecimal balance) {
        System.out.println("--- RECEIPT ---");
        System.out.println("Date: " + LocalDateTime.now());
        System.out.println("Account: " + accountId);
        System.out.println("Type: " + type);
        System.out.println("Amount: " + amount);
        System.out.println("Balance: " + balance);
        System.out.println("--- END ---");
    }
}

What should a senior engineer know?

  • The debit-and-dispense coordination is the heart of ATM correctness. Always debit first, dispense second, reverse on failure.
  • All money uses BigDecimal because an ATM cannot tolerate floating-point rounding.
  • The dispenser must track its note inventory and signal when denominations run low.
  • In production, the ATM communicates with the bank's host system over a secure channel, and the host is the source of truth for the account balance.
  • Transaction types as polymorphic objects keep the ATM orchestrator clean and extensible.

What are common interview follow-up questions?

  • What happens if power fails during dispensing?
  • How do you handle a denomination running out?
  • How would you add a mini-statement transaction type?
  • How does the ATM communicate with the bank's core system?
  • How do you prevent replay attacks on the card?

Visual Overview

DIAGRAM
  ATM State Machine:
  ──────────────────

  IDLE ──insertCard──→ CARD_INSERTED ──enterPIN──→ AUTHENTICATED
    ▲                       │ (3 fails)                │
    │                  retainCard              selectTransaction
    │                       │                          │
    │                       ▼                          ▼
    │                    IDLE                    TRANSACTION
    │                                                  │
    │                                          debit + dispense
    │                                                  │
    └──────────────── ejectCard ──────────── DISPENSING
DIAGRAM
  Withdrawal Atomicity:
  ─────────────────────

  1. account.debit(amount)        ← debit first
  2. dispenser.dispense(amount)   ← then dispense
       │
       ├── success → done
       └── failure → account.credit(amount)  ← reverse
DIAGRAM
  CashDispenser Denomination Breakdown:
  ─────────────────────────────────────

  Amount: ₹5000
  Available: [2000 x 5, 500 x 10, 100 x 20]

  Dispensed: 2 x ₹2000 + 2 x ₹500 = ₹5000

Key Takeaway

When you design an ATM, model it as a state machine coordinated with the bank's account services. The machine moves through idle, card inserted, authenticated, transaction, and dispensing. Authentication validates the entered PIN, and after a few failed attempts the card is retained for security.

Transaction types are modelled polymorphically, with separate classes for withdraw, deposit, and balance inquiry, each knowing how to execute against the account. This keeps the ATM orchestrator clean and lets you add a transaction type like mini-statement as a new class. Withdrawal specifically enforces two independent checks: the account must have sufficient balance, and the amount must fit within the remaining daily limit.

The single most important correctness concern is atomicity between debiting the account and physically dispensing cash. The machine must never debit without dispensing, which would cheat the customer, nor dispense without debiting, which would cheat the bank. The strategy is debit first, then dispense, and if the physical dispense fails, immediately reverse the debit. Real ATMs achieve this with a two-phase protocol against the host system.

All money is handled with BigDecimal because an ATM cannot tolerate rounding error, and the dispenser works in available denominations, failing cleanly when it cannot make the exact amount. The senior insight is that an ATM is a safety-critical, money-exact state machine where the debit-and-dispense coordination is the heart of correctness. This is precisely the kind of transactional integrity built into every money-movement path at National Bank of India.

Remember one sentence: an ATM is a state machine that authenticates, enforces limits, debits first, dispenses second, and reverses the debit if dispensing fails.

---

Share
Use ← → arrow keys to navigate