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