Low-Level Design: ATM Machine (State Pattern Interview)

Low-Level Design: ATM Machine

The ATM Machine is a classic LLD problem that tests state machine design, OOP modeling, and security considerations. It’s popular at fintech companies (Stripe, Coinbase, Visa, banks) and traditional tech firms.

Requirements

  • User inserts card, enters PIN, and authenticates
  • Supported operations: balance inquiry, withdraw cash, deposit, transfer between accounts
  • Dispense correct combination of banknotes for withdrawal
  • Handle insufficient funds, wrong PIN (max 3 attempts → card locked), and out-of-cash scenarios
  • Print or display receipts
  • Session timeout after inactivity

State Machine Design

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

class ATMState(Enum):
    IDLE = auto()          # Waiting for card insertion
    CARD_INSERTED = auto() # Card inserted, awaiting PIN
    AUTHENTICATED = auto() # PIN verified, menu displayed
    PROCESSING = auto()    # Transaction in progress
    OUT_OF_SERVICE = auto()# ATM needs maintenance

class ATMStateHandler(ABC):
    @abstractmethod
    def insert_card(self, atm: 'ATM', card: 'Card'): pass

    @abstractmethod
    def enter_pin(self, atm: 'ATM', pin: str): pass

    @abstractmethod
    def select_operation(self, atm: 'ATM', op: 'Operation'): pass

    @abstractmethod
    def eject_card(self, atm: 'ATM'): pass

class IdleState(ATMStateHandler):
    def insert_card(self, atm, card):
        if card.is_valid():
            atm.current_card = card
            atm.pin_attempts = 0
            atm.set_state(ATMState.CARD_INSERTED)
            atm.display.show("Please enter your PIN")
        else:
            atm.display.show("Invalid card. Please try again.")

    def enter_pin(self, atm, pin):
        atm.display.show("Please insert your card first.")

    def select_operation(self, atm, op):
        atm.display.show("Please insert your card first.")

    def eject_card(self, atm):
        atm.display.show("No card inserted.")

class CardInsertedState(ATMStateHandler):
    MAX_PIN_ATTEMPTS = 3

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

    def enter_pin(self, atm, pin: str):
        if atm.bank.verify_pin(atm.current_card, pin):
            atm.pin_attempts = 0
            atm.set_state(ATMState.AUTHENTICATED)
            atm.display.show_menu()
        else:
            atm.pin_attempts += 1
            remaining = self.MAX_PIN_ATTEMPTS - atm.pin_attempts
            if remaining <= 0:
                atm.bank.lock_card(atm.current_card)
                atm.display.show("Card locked. Contact your bank.")
                atm._eject_and_reset()
            else:
                atm.display.show(f"Incorrect PIN. {remaining} attempt(s) remaining.")

    def select_operation(self, atm, op):
        atm.display.show("Please enter your PIN first.")

    def eject_card(self, atm):
        atm._eject_and_reset()

class AuthenticatedState(ATMStateHandler):
    def insert_card(self, atm, card):
        atm.display.show("Session in progress.")

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

    def select_operation(self, atm, op):
        atm.set_state(ATMState.PROCESSING)
        op.execute(atm)
        atm.set_state(ATMState.AUTHENTICATED)
        atm.display.show_menu()

    def eject_card(self, atm):
        atm._eject_and_reset()

Core Classes

from dataclasses import dataclass
from typing import Optional
import time

@dataclass
class Card:
    card_number: str
    expiry: str
    cardholder_name: str

    def is_valid(self) -> bool:
        # Check expiry format and date
        from datetime import datetime
        try:
            exp = datetime.strptime(self.expiry, "%m/%y")
            return exp > datetime.now()
        except ValueError:
            return False

@dataclass
class Account:
    account_id: str
    balance: float
    is_locked: bool = False

class BankService:
    """Simulates connection to bank backend (network call in real system)."""
    def __init__(self):
        self._accounts = {}
        self._card_pins = {}      # card_number -> hashed_pin
        self._card_accounts = {}  # card_number -> account_id

    def verify_pin(self, card: Card, pin: str) -> bool:
        stored_pin = self._card_pins.get(card.card_number)
        return stored_pin == self._hash_pin(pin)

    def lock_card(self, card: Card):
        if card.card_number in self._card_accounts:
            account_id = self._card_accounts[card.card_number]
            self._accounts[account_id].is_locked = True

    def get_balance(self, card: Card) -> float:
        account = self._get_account(card)
        return account.balance

    def debit(self, card: Card, amount: float) -> bool:
        account = self._get_account(card)
        if account.is_locked or account.balance  Account:
        account_id = self._card_accounts[card.card_number]
        return self._accounts[account_id]

    def _hash_pin(self, pin: str) -> str:
        import hashlib
        return hashlib.sha256(pin.encode()).hexdigest()

class CashDispenser:
    """Manages banknote inventory and dispenses using greedy algorithm."""
    def __init__(self, denominations: list[int]):
        # denominations sorted descending: [100, 50, 20, 10, 5, 1]
        self.denominations = sorted(denominations, reverse=True)
        self.inventory = {d: 20 for d in denominations}  # Start with 20 of each

    def can_dispense(self, amount: int) -> bool:
        return self._calculate_notes(amount) is not None

    def dispense(self, amount: int) -> dict[int, int]:
        notes = self._calculate_notes(amount)
        if not notes:
            raise ValueError("Cannot dispense $" + str(amount))
        for denomination, count in notes.items():
            self.inventory[denomination] -= count
        return notes

    def _calculate_notes(self, amount: int) -> Optional[dict[int, int]]:
        """Greedy: use largest denominations first."""
        remaining = amount
        notes = {}
        for denom in self.denominations:
            if remaining  0:
                notes[denom] = count
                remaining -= count * denom
        return notes if remaining == 0 else None

    def get_total_cash(self) -> int:
        return sum(d * c for d, c in self.inventory.items())

Operations (Command Pattern)

class Operation(ABC):
    @abstractmethod
    def execute(self, atm: 'ATM'): pass

class BalanceInquiry(Operation):
    def execute(self, atm):
        balance = atm.bank.get_balance(atm.current_card)
        atm.display.show("Available balance: $" + f"{balance:.2f}")
        atm.printer.print_receipt(
            "Balance inquiry
Account: ****" + atm.current_card.card_number[-4:] +
            "
Balance: $" + f"{balance:.2f}"
        )

class Withdrawal(Operation):
    def __init__(self, amount: int):
        self.amount = amount

    def execute(self, atm):
        if not atm.cash_dispenser.can_dispense(self.amount):
            atm.display.show("Cannot dispense $" + str(self.amount) + ". Try a different amount.")
            return
        if not atm.bank.debit(atm.current_card, self.amount):
            atm.display.show("Insufficient funds or account locked.")
            return
        notes = atm.cash_dispenser.dispense(self.amount)
        notes_str = ', '.join(str(c) + "x$" + str(d) for d, c in notes.items())
        atm.display.show("Please take your cash: " + notes_str)
        atm.printer.print_receipt(
            "Withdrawal: $" + str(self.amount) + "
Notes: " + notes_str
        )

class Deposit(Operation):
    def __init__(self, amount: float):
        self.amount = amount

    def execute(self, atm):
        # In real ATM: physical cash sensor validates inserted bills
        atm.bank.credit(atm.current_card, self.amount)
        atm.display.show("Deposited $" + f"{self.amount:.2f}" + " successfully.")

class ATM:
    def __init__(self, atm_id: str, bank: BankService, cash_dispenser: CashDispenser):
        self.atm_id = atm_id
        self.bank = bank
        self.cash_dispenser = cash_dispenser
        self.display = Display()
        self.printer = Printer()
        self._state = ATMState.IDLE
        self._state_handlers = {
            ATMState.IDLE: IdleState(),
            ATMState.CARD_INSERTED: CardInsertedState(),
            ATMState.AUTHENTICATED: AuthenticatedState(),
        }
        self.current_card: Optional[Card] = None
        self.pin_attempts = 0
        self._session_start = None

    def set_state(self, state: ATMState):
        self._state = state
        if state == ATMState.AUTHENTICATED:
            self._session_start = time.time()

    def _handler(self) -> ATMStateHandler:
        return self._state_handlers[self._state]

    def insert_card(self, card: Card): self._handler().insert_card(self, card)
    def enter_pin(self, pin: str): self._handler().enter_pin(self, pin)
    def select_operation(self, op: Operation): self._handler().select_operation(self, op)
    def eject_card(self): self._handler().eject_card(self)

    def _eject_and_reset(self):
        self.current_card = None
        self.pin_attempts = 0
        self._session_start = None
        self._state = ATMState.IDLE
        self.display.show("Card ejected. Goodbye.")

    def check_session_timeout(self, timeout_seconds: int = 90):
        """Called periodically by a watchdog thread."""
        if (self._state == ATMState.AUTHENTICATED and
                self._session_start and
                time.time() - self._session_start > timeout_seconds):
            self.display.show("Session timed out.")
            self._eject_and_reset()

Key Design Decisions

  • State pattern: Each ATM state (IDLE, CARD_INSERTED, AUTHENTICATED) has its own handler implementing the same interface. Adding new states doesn’t break existing ones.
  • Command pattern for operations: Each operation (Withdrawal, Deposit, BalanceInquiry) is a separate command object. Easy to add new operations, log/audit commands, or implement undo.
  • Greedy cash dispensing: Use largest denominations first. Works for standard denominations; fails for some combinations (e.g., denominations [6,4], amount=8 needs two 4s, not one 6).
  • PIN hashing: Never store or transmit PIN in plaintext. Hash before comparison. In reality, PIN verification happens at the bank backend via encrypted channel.
  • Separation of concerns: ATM handles UI flow; BankService handles account state; CashDispenser handles hardware; operations encapsulate business logic.

Security Considerations

  • Card data transmitted over TLS; PIN encrypted with bank’s public key before network transmission
  • HSM (Hardware Security Module) handles PIN verification on ATM hardware — software never sees plaintext PIN
  • Anti-skimming: detect foreign devices attached to card reader
  • Camera monitoring: record all sessions for fraud investigation
  • Cash dispenser physical security: tamper-evident seals, alarms on forced opening

{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How do you design an ATM machine using the State pattern?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”The ATM has discrete states: IDLE (waiting for card), CARD_INSERTED (awaiting PIN), AUTHENTICATED (menu displayed), and PROCESSING (transaction running). Each state is a handler class implementing the same interface (insert_card, enter_pin, select_operation, eject_card). The ATM delegates all operations to the current state handler. Invalid operations in a given state (like entering a PIN when no card is inserted) are handled gracefully with user messages rather than exceptions.”}},{“@type”:”Question”,”name”:”How does cash dispensing work in an ATM machine design?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”The CashDispenser uses a greedy algorithm: iterate denominations from largest to smallest, taking as many of each as possible without exceeding the remaining amount. First verify dispensibility (simulate without modifying inventory), then deduct. This greedy approach works for standard denominations (100, 50, 20, 10, 5, 1) but can fail for unusual denomination sets — for those, dynamic programming is required. The dispenser also tracks inventory and can signal low-cash warnings.”}},{“@type”:”Question”,”name”:”How do you handle wrong PIN and card locking in ATM design?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Track pin_attempts counter per session. On incorrect PIN, increment counter and show remaining attempts. After 3 failures, call bank.lock_card() to lock the account, eject the card, and reset ATM to IDLE state. The card locking persists in the bank backend — the customer must call the bank to unlock. PIN verification happens through the bank service (network call in production), never by comparing on the ATM itself, to prevent offline attacks.”}},{“@type”:”Question”,”name”:”What design patterns are used in ATM machine design?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”State pattern: ATM behavior changes based on current state (IDLE, CARD_INSERTED, AUTHENTICATED); each state is a class. Command pattern: each operation (BalanceInquiry, Withdrawal, Deposit) is an object with an execute() method — enables logging, audit trails, and undo. Strategy pattern: different fee structures or transaction limits can be injected as strategies. Singleton: one ATM controller instance manages hardware resources.”}}]}

🏢 Asked at: Stripe Interview Guide 2026: Process, Bug Bash Round, and Payment Systems

🏢 Asked at: Coinbase Interview Guide

🏢 Asked at: Uber Interview Guide 2026: Dispatch Systems, Geospatial Algorithms, and Marketplace Engineering

🏢 Asked at: Atlassian Interview Guide

🏢 Asked at: Shopify Interview Guide

🏢 Asked at: DoorDash Interview Guide

Scroll to Top