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