Design ATM Machine

advanced 4-7 YOE lld atm design-question state-pattern

The ATM question is an interviewer favorite because it naturally maps to the State pattern. An ATM behaves completely differently depending on what step we’re at: idle and waiting for a card, authenticating a PIN, showing the main menu, dispensing cash. Same machine, wildly different behavior based on state.

If we did the State pattern note earlier, this is where we put it to work in a real design.

Requirements

Functional:

  • Insert card and authenticate with PIN (3 attempts max)
  • Check balance
  • Withdraw cash (must have sufficient balance, ATM must have enough cash)
  • Deposit cash
  • Transfer between accounts
  • Print receipt after each transaction
  • Eject card and return to idle

Constraints:

  • Daily withdrawal limit of $5,000 per card
  • ATM has finite cash in its dispenser
  • Card gets retained after 3 failed PIN attempts
  • Each operation is a transaction that gets logged

Key Classes & Relationships

ATM — Class Diagram
ATM
- state: ATMState
- cashDispenser: CashDispenser
- currentCard: Card?
- currentAccount: Account?
+ insertCard(card)
+ enterPin(pin)
+ selectOption(option)
+ ejectCard()
│ delegates to current state
ATMState (interface)
+ insertCard(atm, card)
+ enterPin(atm, pin)
+ selectOption(atm, option)
+ ejectCard(atm)
│ implemented by
IdleState
AuthState
MenuState
TransactionState
ATM also has ↓
CashDispenser
- cashAvailable: float
+ dispense(amount)
+ deposit(amount)
Transaction
- type: TransactionType
- amount: float
- timestamp: Date
- status: success/failed
Account
- id: string
- balance: float
- dailyWithdrawn: float

The ATM delegates ALL user actions to its current state. Each state knows which actions make sense and which don’t. Inserting a card in idle? Great, move to auth. Inserting a card while already authenticated? Nope, ignore it.

Core Implementation

from abc import ABC, abstractmethod
from enum import Enum
from datetime import datetime

class TransactionType(Enum):
    WITHDRAWAL = "withdrawal"
    DEPOSIT = "deposit"
    BALANCE_CHECK = "balance_check"
    TRANSFER = "transfer"

# --- Supporting Classes ---

class Account:
    DAILY_LIMIT = 5000.0

    def __init__(self, account_id: str, balance: float):
        self.account_id = account_id
        self.balance = balance
        self.daily_withdrawn = 0.0

    def can_withdraw(self, amount: float) -> bool:
        return (self.balance >= amount
                and self.daily_withdrawn + amount <= self.DAILY_LIMIT)

class Card:
    def __init__(self, card_number: str, pin: str, account: Account):
        self.card_number = card_number
        self.pin = pin
        self.account = account

class CashDispenser:
    def __init__(self, initial_cash: float):
        self.cash_available = initial_cash

    def can_dispense(self, amount: float) -> bool:
        return self.cash_available >= amount

    def dispense(self, amount: float):
        self.cash_available -= amount
        print(f"  Dispensing ${amount:.2f}... (ATM has ${self.cash_available:.2f} left)")

    def deposit(self, amount: float):
        self.cash_available += amount

class Transaction:
    def __init__(self, tx_type: TransactionType, amount: float, account_id: str):
        self.tx_type = tx_type
        self.amount = amount
        self.account_id = account_id
        self.timestamp = datetime.now()
        self.success = False

    def __str__(self):
        status = "SUCCESS" if self.success else "FAILED"
        return f"[{self.timestamp:%H:%M}] {self.tx_type.value} ${self.amount:.2f} - {status}"

# --- State Pattern ---

class ATMState(ABC):
    @abstractmethod
    def insert_card(self, atm, card: Card): pass
    @abstractmethod
    def enter_pin(self, atm, pin: str): pass
    @abstractmethod
    def select_option(self, atm, option: str, amount: float = 0): pass
    @abstractmethod
    def eject_card(self, atm): pass

class IdleState(ATMState):
    def insert_card(self, atm, card):
        atm.current_card = card
        atm.pin_attempts = 0
        print(f"Card {card.card_number} inserted. Enter your PIN.")
        atm.set_state(AuthState())

    def enter_pin(self, atm, pin):
        print("Please insert a card first.")

    def select_option(self, atm, option, amount=0):
        print("Please insert a card first.")

    def eject_card(self, atm):
        print("No card to eject.")

class AuthState(ATMState):
    MAX_ATTEMPTS = 3

    def insert_card(self, atm, card):
        print("Card already inserted.")

    def enter_pin(self, atm, pin):
        if atm.current_card.pin == pin:
            atm.current_account = atm.current_card.account
            print("PIN correct. Welcome!")
            atm.set_state(MenuState())
        else:
            atm.pin_attempts += 1
            remaining = self.MAX_ATTEMPTS - atm.pin_attempts
            if remaining <= 0:
                print("Too many failed attempts. Card retained.")
                atm.current_card = None
                atm.set_state(IdleState())
            else:
                print(f"Wrong PIN. {remaining} attempt(s) left.")

    def select_option(self, atm, option, amount=0):
        print("Please enter your PIN first.")

    def eject_card(self, atm):
        print("Card ejected.")
        atm.current_card = None
        atm.set_state(IdleState())

class MenuState(ATMState):
    def insert_card(self, atm, card):
        print("Session in progress.")

    def enter_pin(self, atm, pin):
        print("Already authenticated.")

    def select_option(self, atm, option, amount=0):
        if option == "balance":
            print(f"  Your balance: ${atm.current_account.balance:.2f}")
            tx = Transaction(TransactionType.BALANCE_CHECK, 0, atm.current_account.account_id)
            tx.success = True
            atm.transactions.append(tx)

        elif option == "withdraw":
            atm.set_state(TransactionState())
            atm.state.process_withdrawal(atm, amount)

        elif option == "deposit":
            atm.set_state(TransactionState())
            atm.state.process_deposit(atm, amount)

        elif option == "eject":
            atm.eject_card()

        else:
            print("Invalid option.")

    def eject_card(self, atm):
        print("Card ejected. Goodbye!")
        atm.current_card = None
        atm.current_account = None
        atm.set_state(IdleState())

class TransactionState(ATMState):
    def insert_card(self, atm, card):
        print("Transaction in progress.")

    def enter_pin(self, atm, pin):
        print("Transaction in progress.")

    def select_option(self, atm, option, amount=0):
        print("Transaction in progress. Please wait.")

    def eject_card(self, atm):
        print("Cannot eject during transaction.")

    def process_withdrawal(self, atm, amount: float):
        account = atm.current_account
        tx = Transaction(TransactionType.WITHDRAWAL, amount, account.account_id)

        if not account.can_withdraw(amount):
            print(f"  Cannot withdraw ${amount:.2f} (balance: ${account.balance:.2f}, "
                  f"daily used: ${account.daily_withdrawn:.2f})")
            atm.transactions.append(tx)
            atm.set_state(MenuState())
            return

        if not atm.cash_dispenser.can_dispense(amount):
            print("  ATM doesn't have enough cash.")
            atm.transactions.append(tx)
            atm.set_state(MenuState())
            return

        account.balance -= amount
        account.daily_withdrawn += amount
        atm.cash_dispenser.dispense(amount)
        tx.success = True
        atm.transactions.append(tx)
        print(f"  New balance: ${account.balance:.2f}")
        atm.set_state(MenuState())

    def process_deposit(self, atm, amount: float):
        account = atm.current_account
        tx = Transaction(TransactionType.DEPOSIT, amount, account.account_id)
        account.balance += amount
        atm.cash_dispenser.deposit(amount)
        tx.success = True
        atm.transactions.append(tx)
        print(f"  Deposited ${amount:.2f}. New balance: ${account.balance:.2f}")
        atm.set_state(MenuState())

# --- ATM (Context) ---

class ATM:
    def __init__(self, cash: float):
        self.cash_dispenser = CashDispenser(cash)
        self.state: ATMState = IdleState()
        self.current_card: Card | None = None
        self.current_account: Account | None = None
        self.pin_attempts = 0
        self.transactions: list[Transaction] = []

    def set_state(self, state: ATMState):
        self.state = state

    def insert_card(self, card: Card):
        self.state.insert_card(self, card)

    def enter_pin(self, pin: str):
        self.state.enter_pin(self, pin)

    def select_option(self, option: str, amount: float = 0):
        self.state.select_option(self, option, amount)

    def eject_card(self):
        self.state.eject_card(self)

    def print_receipt(self):
        if self.transactions:
            print("\n--- Receipt ---")
            for tx in self.transactions[-3:]:  # last 3 transactions
                print(f"  {tx}")
            print("---------------")

# Usage
account = Account("ACC-001", 10000.0)
card = Card("4111-1111-1111-1111", "1234", account)
atm = ATM(50000.0)

atm.insert_card(card)             # Card inserted. Enter your PIN.
atm.enter_pin("0000")            # Wrong PIN. 2 attempt(s) left.
atm.enter_pin("1234")            # PIN correct. Welcome!
atm.select_option("balance")     # Your balance: $10000.00
atm.select_option("withdraw", 500)  # Dispensing $500.00...
atm.select_option("deposit", 200)   # Deposited $200.00.
atm.print_receipt()
atm.select_option("eject")       # Card ejected. Goodbye!
const TransactionType = Object.freeze({
  WITHDRAWAL: "withdrawal",
  DEPOSIT: "deposit",
  BALANCE_CHECK: "balance_check",
});

class Account {
  static DAILY_LIMIT = 5000;
  constructor(id, balance) {
    this.id = id;
    this.balance = balance;
    this.dailyWithdrawn = 0;
  }
  canWithdraw(amount) {
    return this.balance >= amount && this.dailyWithdrawn + amount <= Account.DAILY_LIMIT;
  }
}

class Card {
  constructor(number, pin, account) {
    this.number = number;
    this.pin = pin;
    this.account = account;
  }
}

class CashDispenser {
  constructor(cash) { this.cashAvailable = cash; }
  canDispense(amount) { return this.cashAvailable >= amount; }
  dispense(amount) {
    this.cashAvailable -= amount;
    console.log(`  Dispensing $${amount}...`);
  }
  deposit(amount) { this.cashAvailable += amount; }
}

class Transaction {
  constructor(type, amount, accountId) {
    this.type = type;
    this.amount = amount;
    this.accountId = accountId;
    this.timestamp = new Date();
    this.success = false;
  }
}

// --- States ---
class IdleState {
  insertCard(atm, card) {
    atm.currentCard = card;
    atm.pinAttempts = 0;
    console.log("Card inserted. Enter PIN.");
    atm.setState(new AuthState());
  }
  enterPin(atm, pin) { console.log("Insert a card first."); }
  selectOption(atm) { console.log("Insert a card first."); }
  ejectCard(atm) { console.log("No card to eject."); }
}

class AuthState {
  static MAX_ATTEMPTS = 3;
  insertCard(atm) { console.log("Card already inserted."); }
  enterPin(atm, pin) {
    if (atm.currentCard.pin === pin) {
      atm.currentAccount = atm.currentCard.account;
      console.log("PIN correct. Welcome!");
      atm.setState(new MenuState());
    } else {
      atm.pinAttempts++;
      const left = AuthState.MAX_ATTEMPTS - atm.pinAttempts;
      if (left <= 0) {
        console.log("Card retained. Too many failures.");
        atm.currentCard = null;
        atm.setState(new IdleState());
      } else {
        console.log(`Wrong PIN. ${left} attempt(s) left.`);
      }
    }
  }
  selectOption(atm) { console.log("Enter PIN first."); }
  ejectCard(atm) {
    console.log("Card ejected.");
    atm.currentCard = null;
    atm.setState(new IdleState());
  }
}

class MenuState {
  insertCard(atm) { console.log("Session in progress."); }
  enterPin(atm) { console.log("Already authenticated."); }
  selectOption(atm, option, amount = 0) {
    const account = atm.currentAccount;
    if (option === "balance") {
      console.log(`  Balance: $${account.balance}`);
    } else if (option === "withdraw") {
      if (!account.canWithdraw(amount)) { console.log("  Insufficient funds/limit."); return; }
      if (!atm.cashDispenser.canDispense(amount)) { console.log("  ATM low on cash."); return; }
      account.balance -= amount;
      account.dailyWithdrawn += amount;
      atm.cashDispenser.dispense(amount);
      console.log(`  New balance: $${account.balance}`);
    } else if (option === "deposit") {
      account.balance += amount;
      atm.cashDispenser.deposit(amount);
      console.log(`  Deposited $${amount}. Balance: $${account.balance}`);
    } else if (option === "eject") {
      atm.ejectCard();
    }
  }
  ejectCard(atm) {
    console.log("Card ejected. Goodbye!");
    atm.currentCard = null;
    atm.currentAccount = null;
    atm.setState(new IdleState());
  }
}

// --- ATM ---
class ATM {
  constructor(cash) {
    this.cashDispenser = new CashDispenser(cash);
    this.state = new IdleState();
    this.currentCard = null;
    this.currentAccount = null;
    this.pinAttempts = 0;
  }
  setState(s) { this.state = s; }
  insertCard(card) { this.state.insertCard(this, card); }
  enterPin(pin) { this.state.enterPin(this, pin); }
  selectOption(opt, amt) { this.state.selectOption(this, opt, amt); }
  ejectCard() { this.state.ejectCard(this); }
}

// Usage
const acc = new Account("ACC-001", 10000);
const card = new Card("4111-XXXX", "1234", acc);
const atm = new ATM(50000);

atm.insertCard(card);
atm.enterPin("1234");
atm.selectOption("balance");
atm.selectOption("withdraw", 500);
atm.selectOption("eject");
import java.util.*;

enum TransactionType { WITHDRAWAL, DEPOSIT, BALANCE_CHECK }

class Account {
    static final double DAILY_LIMIT = 5000;
    String id;
    double balance, dailyWithdrawn;

    Account(String id, double balance) { this.id = id; this.balance = balance; }
    boolean canWithdraw(double amount) {
        return balance >= amount && dailyWithdrawn + amount <= DAILY_LIMIT;
    }
}

class Card {
    String number, pin;
    Account account;
    Card(String number, String pin, Account account) {
        this.number = number; this.pin = pin; this.account = account;
    }
}

class CashDispenser {
    double cashAvailable;
    CashDispenser(double cash) { this.cashAvailable = cash; }
    boolean canDispense(double amount) { return cashAvailable >= amount; }
    void dispense(double amount) {
        cashAvailable -= amount;
        System.out.println("  Dispensing $" + amount);
    }
    void deposit(double amount) { cashAvailable += amount; }
}

// --- State Interface ---
interface ATMState {
    void insertCard(ATM atm, Card card);
    void enterPin(ATM atm, String pin);
    void selectOption(ATM atm, String option, double amount);
    void ejectCard(ATM atm);
}

class IdleState implements ATMState {
    public void insertCard(ATM atm, Card card) {
        atm.currentCard = card;
        atm.pinAttempts = 0;
        System.out.println("Card inserted. Enter PIN.");
        atm.setState(new AuthState());
    }
    public void enterPin(ATM atm, String pin) { System.out.println("Insert card first."); }
    public void selectOption(ATM atm, String opt, double amt) { System.out.println("Insert card first."); }
    public void ejectCard(ATM atm) { System.out.println("No card."); }
}

class AuthState implements ATMState {
    static final int MAX_ATTEMPTS = 3;
    public void insertCard(ATM atm, Card card) { System.out.println("Card already in."); }
    public void enterPin(ATM atm, String pin) {
        if (atm.currentCard.pin.equals(pin)) {
            atm.currentAccount = atm.currentCard.account;
            System.out.println("PIN correct. Welcome!");
            atm.setState(new MenuState());
        } else {
            atm.pinAttempts++;
            int left = MAX_ATTEMPTS - atm.pinAttempts;
            if (left <= 0) {
                System.out.println("Card retained.");
                atm.currentCard = null;
                atm.setState(new IdleState());
            } else {
                System.out.println("Wrong PIN. " + left + " left.");
            }
        }
    }
    public void selectOption(ATM atm, String opt, double amt) { System.out.println("Enter PIN first."); }
    public void ejectCard(ATM atm) {
        atm.currentCard = null;
        atm.setState(new IdleState());
        System.out.println("Card ejected.");
    }
}

class MenuState implements ATMState {
    public void insertCard(ATM atm, Card card) { System.out.println("Session active."); }
    public void enterPin(ATM atm, String pin) { System.out.println("Already authenticated."); }
    public void selectOption(ATM atm, String option, double amount) {
        Account acc = atm.currentAccount;
        switch (option) {
            case "balance":
                System.out.println("  Balance: $" + acc.balance);
                break;
            case "withdraw":
                if (!acc.canWithdraw(amount)) { System.out.println("  Insufficient funds/limit."); return; }
                if (!atm.cashDispenser.canDispense(amount)) { System.out.println("  ATM low."); return; }
                acc.balance -= amount;
                acc.dailyWithdrawn += amount;
                atm.cashDispenser.dispense(amount);
                System.out.println("  New balance: $" + acc.balance);
                break;
            case "deposit":
                acc.balance += amount;
                atm.cashDispenser.deposit(amount);
                System.out.println("  Deposited. Balance: $" + acc.balance);
                break;
            case "eject":
                atm.ejectCard();
                break;
        }
    }
    public void ejectCard(ATM atm) {
        System.out.println("Goodbye!");
        atm.currentCard = null;
        atm.currentAccount = null;
        atm.setState(new IdleState());
    }
}

class ATM {
    CashDispenser cashDispenser;
    ATMState state;
    Card currentCard;
    Account currentAccount;
    int pinAttempts;

    ATM(double cash) {
        this.cashDispenser = new CashDispenser(cash);
        this.state = new IdleState();
    }
    void setState(ATMState s) { this.state = s; }
    void insertCard(Card card) { state.insertCard(this, card); }
    void enterPin(String pin) { state.enterPin(this, pin); }
    void selectOption(String opt, double amt) { state.selectOption(this, opt, amt); }
    void ejectCard() { state.ejectCard(this); }
}

Design Patterns Used

State Pattern (the core) — This is THE pattern for ATMs. The ATM delegates every action to its current state object. IdleState only accepts card insertion. AuthState only accepts PIN entry. MenuState handles option selection. Each state manages its own transitions. No massive if-else chain in the ATM class.

Facade — The ATM is a facade over the cash dispenser, card reader, and account system. The user doesn’t interact with CashDispenser directly — they call atm.selectOption("withdraw", 500) and the ATM coordinates everything behind the scenes.

Command Pattern (for transactions) — Each Transaction is a record of what happened. In a more advanced version, we’d make transactions into command objects with execute() and rollback() methods. If dispensing cash fails after debiting the account, we’d roll back the debit.

SRP at work:

  • CashDispenser — only knows about physical cash
  • Account — only knows about balances and limits
  • ATMState implementations — only know about behavior for that state
  • ATM — just delegates and holds shared context

Extensions

“Add multi-currency support” — The CashDispenser holds multiple denominations. We’d add a DenominationStrategy that figures out how to make up the requested amount (e.g., 3x $100 + 1x $50 + 2x $20 + 1x $10 = $500). This is actually a greedy algorithm problem.

“Add daily withdrawal limit tracking” — We already have daily_withdrawn on Account. In production, this resets at midnight. We’d add a last_withdrawal_date field and reset daily_withdrawn if the date has changed.

“Add receipt printing” — Create a ReceiptPrinter class with a print(transaction) method. The ATM calls it after each successful transaction. Nice spot for the Observer pattern — register the printer as a listener for transaction events.

“Add admin mode” — A special state (AdminState) that a technician enters with a service card. Allows refilling cash, viewing transaction logs, and resetting the machine. Just another state in our state machine.

“Handle hardware failures” — What if the cash dispenser jams mid-withdrawal? This is where the Command pattern really shines. The withdrawal command would: 1) debit account, 2) dispense cash. If step 2 fails, step 1 gets rolled back. Each step is reversible.

In simple language, the ATM is a state machine wearing a trench coat. Every button press gets routed to whatever state the machine is in. The state decides what to do and what state comes next. Adding a new flow (like transfers) means adding a new state class — nothing else changes. That’s the beauty of the State pattern in action.