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

🏢 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

Asked at: Airbnb Interview Guide

Scroll to Top