Skip to content
1 min read L2

Design an ATM System

Interview Context

Asked at: Google, Amazon, Microsoft, Goldman Sachs | Level: L4-L6 | Time: 45 minutes | Type: LLD/OOP Design (Hard — State Machine + Chain of Responsibility)


Requirements

Functional

  • Support operations: Withdrawal, Balance Inquiry, Deposit, Transfer
  • Validate card (expiry, blocked status) and authenticate via PIN
  • Block card after 3 consecutive failed PIN attempts
  • Dispense cash using available denominations (₹2000, ₹500, ₹200, ₹100)
  • Maintain cash inventory per denomination; reject if insufficient
  • Print transaction receipt with details

Non-Functional

  • Only one user per ATM at any time (single-session concurrency)
  • State transitions must be atomic (no partial state corruption)
  • Transaction timeout: auto-return to IDLE after 30 seconds of inactivity
  • Audit log for every transaction (regulatory compliance)

Class Diagram

%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
classDiagram
    class ATM {
        -String atmId
        -ATMState currentState
        -CashDispenser cashDispenser
        -CardReader cardReader
        -Session currentSession
        +insertCard(Card) void
        +enterPin(String) boolean
        +selectTransaction(TransactionType) void
        +processTransaction(TransactionRequest) TransactionResult
        +ejectCard() void
    }

    class ATMState {
        <<enumeration>>
        IDLE
        CARD_INSERTED
        PIN_VERIFIED
        TRANSACTION_SELECTED
        DISPENSING
        COMPLETED
    }

    class Session {
        -Card card
        -Account account
        -int pinAttempts
        -TransactionType selectedType
        -LocalDateTime startTime
        +incrementPinAttempts() void
        +isCardBlocked() boolean
    }

    class Card {
        -String cardNumber
        -String bankCode
        -LocalDate expiryDate
        -boolean blocked
        +isExpired() boolean
    }

    class Account {
        -String accountId
        -BigDecimal balance
        +debit(BigDecimal) boolean
        +credit(BigDecimal) void
        +getBalance() BigDecimal
    }

    class CashDispenser {
        -Map~Denomination, Integer~ inventory
        -DenominationHandler handlerChain
        +dispense(int amount) CashBundle
        +canDispense(int amount) boolean
    }

    class DenominationHandler {
        <<abstract>>
        #Denomination denomination
        #DenominationHandler next
        +handle(DispenseRequest) void
        +setNext(DenominationHandler) void
    }

    class TransactionHandler {
        <<interface>>
        +execute(Session, TransactionRequest) TransactionResult
    }

    ATM --> ATMState
    ATM "1" --> "1" CashDispenser
    ATM "1" --> "0..1" Session
    Session --> Card
    Session --> Account
    CashDispenser --> DenominationHandler
    DenominationHandler --> DenominationHandler : next

    class TransactionType {
        <<enumeration>>
        WITHDRAWAL
        BALANCE_INQUIRY
        DEPOSIT
        TRANSFER
    }

    class Denomination {
        <<enumeration>>
        TWO_THOUSAND
        FIVE_HUNDRED
        TWO_HUNDRED
        ONE_HUNDRED
    }

Key Design Decisions

Decision Choice Why
ATM flow control State Machine Enforces valid transitions, prevents illegal operations (e.g., withdraw before PIN)
Cash dispensing Chain of Responsibility Each denomination handler processes what it can, passes remainder to next
Transaction types Strategy Pattern Withdrawal, Deposit, Transfer are interchangeable handlers
Session management Session object per interaction Encapsulates card, account, attempts — garbage collected on eject
Concurrency Single session lock Physical constraint: one user at a time, no complex locking needed

Java Implementation

Java
public enum ATMState {
    IDLE, CARD_INSERTED, PIN_VERIFIED, TRANSACTION_SELECTED, DISPENSING, COMPLETED
}

public enum Denomination {
    TWO_THOUSAND(2000), FIVE_HUNDRED(500), TWO_HUNDRED(200), ONE_HUNDRED(100);
    private final int value;
    Denomination(int value) { this.value = value; }
    public int getValue() { return value; }
}

public enum TransactionType { WITHDRAWAL, BALANCE_INQUIRY, DEPOSIT, TRANSFER }

public class Card {
    private final String cardNumber;
    private final String bankCode;
    private final LocalDate expiryDate;
    private boolean blocked;

    public boolean isExpired() { return LocalDate.now().isAfter(expiryDate); }
    public boolean isBlocked() { return blocked; }
    public void block() { this.blocked = true; }
}

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

    public synchronized boolean debit(BigDecimal amount) {
        if (balance.compareTo(amount) < 0) return false;
        balance = balance.subtract(amount);
        return true;
    }

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

    public BigDecimal getBalance() { return balance; }
}

public class Session {
    private final Card card;
    private final Account account;
    private int pinAttempts;
    private TransactionType selectedType;
    private final LocalDateTime startTime = LocalDateTime.now();

    private static final int MAX_PIN_ATTEMPTS = 3;

    public void incrementPinAttempts() { pinAttempts++; }

    public boolean isCardBlocked() {
        if (pinAttempts >= MAX_PIN_ATTEMPTS) {
            card.block();
            return true;
        }
        return false;
    }
}
Java
public class ATM {
    private final String atmId;
    private ATMState state = ATMState.IDLE;
    private final CashDispenser cashDispenser;
    private final BankService bankService; // external API
    private Session currentSession;
    private final ReentrantLock sessionLock = new ReentrantLock();

    public void insertCard(Card card) {
        if (state != ATMState.IDLE) throw new IllegalStateException("ATM busy");
        sessionLock.lock();
        try {
            if (card.isExpired()) throw new CardExpiredException();
            if (card.isBlocked()) throw new CardBlockedException();
            Account account = bankService.lookupAccount(card);
            currentSession = new Session(card, account);
            transition(ATMState.CARD_INSERTED);
        } catch (Exception e) {
            sessionLock.unlock();
            throw e;
        }
    }

    public boolean enterPin(String pin) {
        requireState(ATMState.CARD_INSERTED);
        boolean valid = bankService.validatePin(currentSession.getCard(), pin);
        if (valid) {
            transition(ATMState.PIN_VERIFIED);
            return true;
        }
        currentSession.incrementPinAttempts();
        if (currentSession.isCardBlocked()) {
            ejectCard();
            throw new CardBlockedException("3 failed attempts — card blocked");
        }
        return false;
    }

    public void selectTransaction(TransactionType type) {
        requireState(ATMState.PIN_VERIFIED);
        currentSession.setSelectedType(type);
        transition(ATMState.TRANSACTION_SELECTED);
    }

    public TransactionResult processTransaction(TransactionRequest request) {
        requireState(ATMState.TRANSACTION_SELECTED);
        TransactionHandler handler = TransactionHandlerFactory.create(
            currentSession.getSelectedType(), cashDispenser, bankService
        );
        TransactionResult result = handler.execute(currentSession, request);
        transition(result.isSuccess() ? ATMState.DISPENSING : ATMState.COMPLETED);
        return result;
    }

    public void ejectCard() {
        currentSession = null;
        transition(ATMState.IDLE);
        sessionLock.unlock();
    }

    private void transition(ATMState newState) {
        this.state = newState;
        AuditLog.log(atmId, state, newState);
    }

    private void requireState(ATMState expected) {
        if (state != expected)
            throw new IllegalStateException("Expected " + expected + " but was " + state);
    }
}
Java
public class CashDispenser {
    private final Map<Denomination, Integer> inventory = new EnumMap<>(Denomination.class);
    private DenominationHandler handlerChain;

    public CashDispenser(Map<Denomination, Integer> initialInventory) {
        this.inventory.putAll(initialInventory);
        buildChain();
    }

    private void buildChain() {
        // Chain: ₹2000 → ₹500 → ₹200 → ₹100 (greedy, largest first)
        DenominationHandler h2000 = new DenominationHandler(Denomination.TWO_THOUSAND, inventory);
        DenominationHandler h500 = new DenominationHandler(Denomination.FIVE_HUNDRED, inventory);
        DenominationHandler h200 = new DenominationHandler(Denomination.TWO_HUNDRED, inventory);
        DenominationHandler h100 = new DenominationHandler(Denomination.ONE_HUNDRED, inventory);
        h2000.setNext(h500);
        h500.setNext(h200);
        h200.setNext(h100);
        this.handlerChain = h2000;
    }

    public boolean canDispense(int amount) {
        if (amount % 100 != 0) return false;
        // Simulate without modifying inventory
        return simulateDispense(amount);
    }

    public CashBundle dispense(int amount) {
        if (!canDispense(amount)) throw new InsufficientCashException();
        DispenseRequest request = new DispenseRequest(amount);
        handlerChain.handle(request);
        return request.getResult();
    }

    public int getTotalCash() {
        return inventory.entrySet().stream()
            .mapToInt(e -> e.getKey().getValue() * e.getValue())
            .sum();
    }
}

public class DenominationHandler {
    private final Denomination denomination;
    private final Map<Denomination, Integer> inventory;
    private DenominationHandler next;

    public DenominationHandler(Denomination denomination, Map<Denomination, Integer> inventory) {
        this.denomination = denomination;
        this.inventory = inventory;
    }

    public void setNext(DenominationHandler next) { this.next = next; }

    public void handle(DispenseRequest request) {
        int remaining = request.getRemainingAmount();
        int notesNeeded = remaining / denomination.getValue();
        int notesAvailable = inventory.getOrDefault(denomination, 0);
        int notesToDispense = Math.min(notesNeeded, notesAvailable);

        if (notesToDispense > 0) {
            inventory.put(denomination, notesAvailable - notesToDispense);
            request.addNotes(denomination, notesToDispense);
        }

        if (request.getRemainingAmount() > 0 && next != null) {
            next.handle(request);
        }
    }
}

public class DispenseRequest {
    private int remainingAmount;
    private final Map<Denomination, Integer> dispensed = new EnumMap<>(Denomination.class);

    public DispenseRequest(int amount) { this.remainingAmount = amount; }

    public void addNotes(Denomination denom, int count) {
        dispensed.put(denom, count);
        remainingAmount -= denom.getValue() * count;
    }

    public int getRemainingAmount() { return remainingAmount; }
    public CashBundle getResult() { return new CashBundle(dispensed); }
}
Java
public interface TransactionHandler {
    TransactionResult execute(Session session, TransactionRequest request);
}

public class WithdrawalHandler implements TransactionHandler {
    private final CashDispenser cashDispenser;
    private final BankService bankService;

    @Override
    public TransactionResult execute(Session session, TransactionRequest request) {
        int amount = request.getAmount();

        // Step 1: Check ATM has enough cash
        if (!cashDispenser.canDispense(amount)) {
            return TransactionResult.failure("ATM has insufficient cash");
        }

        // Step 2: Debit account
        if (!session.getAccount().debit(BigDecimal.valueOf(amount))) {
            return TransactionResult.failure("Insufficient account balance");
        }

        // Step 3: Dispense cash
        CashBundle bundle = cashDispenser.dispense(amount);
        return TransactionResult.success(bundle);
    }
}

public class BalanceInquiryHandler implements TransactionHandler {
    @Override
    public TransactionResult execute(Session session, TransactionRequest request) {
        BigDecimal balance = session.getAccount().getBalance();
        return TransactionResult.success("Balance: " + balance);
    }
}

public class TransferHandler implements TransactionHandler {
    private final BankService bankService;

    @Override
    public TransactionResult execute(Session session, TransactionRequest request) {
        Account source = session.getAccount();
        Account target = bankService.lookupAccount(request.getTargetAccountId());
        if (!source.debit(request.getAmountAsBigDecimal())) {
            return TransactionResult.failure("Insufficient balance");
        }
        target.credit(request.getAmountAsBigDecimal());
        return TransactionResult.success("Transferred " + request.getAmount());
    }
}

public class TransactionHandlerFactory {
    public static TransactionHandler create(TransactionType type,
            CashDispenser dispenser, BankService bankService) {
        return switch (type) {
            case WITHDRAWAL -> new WithdrawalHandler(dispenser, bankService);
            case BALANCE_INQUIRY -> new BalanceInquiryHandler();
            case DEPOSIT -> new DepositHandler(bankService);
            case TRANSFER -> new TransferHandler(bankService);
        };
    }
}

SOLID Principles Applied

Principle How Applied
S — Single Responsibility ATM manages state flow; CashDispenser manages inventory; TransactionHandler executes business logic
O — Open/Closed New transaction types (e.g., bill payment) added as new TransactionHandler — no existing code modified
L — Liskov Substitution Any TransactionHandler implementation works in processTransaction() without caller changes
I — Interface Segregation TransactionHandler has one method; DenominationHandler has one concern (its denomination)
D — Dependency Inversion ATM depends on TransactionHandler interface and BankService interface, not concrete classes

Scaling Considerations (If Interviewer Asks)

"What if..." Answer
Bank network is slow/down Timeout + retry with circuit breaker; offline mode for balance cached locally
Multiple denominations per country Make Denomination configurable; Chain of Responsibility adapts to any set of notes
ATM network (1000 machines) Central inventory service pushes replenishment alerts when cash < threshold
Fraud detection Event stream from audit log → rules engine flags anomalies (rapid withdrawals, odd hours)
Card-less withdrawal (UPI/QR) Add AuthenticationStrategy interface — card auth and QR auth are interchangeable
Daily withdrawal limits Add LimitChecker as a decorator around WithdrawalHandler

Common Interview Mistakes

Mistake Why It's Wrong
Skipping the state machine Without states, you can't prevent "withdraw before PIN" — instant reject from interviewer
Hardcoding denominations Violates OCP — what about ₹50 notes or USD $20? Chain of Responsibility solves this
Ignoring PIN retry logic 3-attempt lockout is a core ATM security requirement
No separation between ATM cash and account balance ATM can have ₹0 cash while your account has ₹1M — two independent checks
Using double for money Floating-point errors in financial systems — always use BigDecimal
Over-engineering concurrency One user per ATM at a time — a simple lock is sufficient, no need for CAS or STM

Interview Walkthrough (45 minutes)

Time What to Do
0-5 min Clarify: transaction types, denominations, PIN rules, card validation, receipt?
5-12 min Draw state machine diagram (IDLE → ... → COMPLETED) and explain transitions
12-20 min Draw class diagram: ATM, Session, CashDispenser, TransactionHandler
20-30 min Code: ATM state machine + Chain of Responsibility for cash dispensing
30-38 min Code: WithdrawalHandler showing account debit + dispense coordination
38-45 min Discuss: SOLID mapping, PIN lockout edge case, what-if extensions