Design an ATM Machine: Transactions, State, and Security in LLD
The ATM problem looks like a vending machine problem with a PIN check. Insert card, enter PIN, pick a transaction, get money. Once you start tracing through failure cases though, the interesting complexity emerges.
What happens if the user enters the correct PIN but the network call to the bank times out during a withdrawal? The money either dispensed or it didn’t. The account either debited or it didn’t. If those two things happen independently without any coordination, you have a window where the machine hands out cash but the account balance doesn’t decrease, or the balance decreases but no cash comes out. In a full distributed system, solving this properly involves two-phase commit, saga patterns, and idempotency keys. In an LLD exercise, the lesson is simpler but equally important: model the transaction as a unit. Either the whole thing succeeds, or none of it does.
The other thing that makes ATMs interesting to reason through is the security model. The PIN validation is not just a boolean check. It has retry limits, lockout behavior, and it affects the state of the session rather than just gating a single operation. Getting that right means the state machine needs to be more precise than it looks at first glance.
Let me walk through how I’d structure this.
Requirements
Functional:
- Accept card insertion and validate PIN (with a retry limit)
- Support withdraw, deposit, check balance, and transfer transactions
- Dispense cash using available denominations, minimizing bill count
- Return the card and end the session after any transaction or explicit logout
- Lock the account after three consecutive failed PIN attempts
Non-functional:
- A transaction must be atomic: the account state and the cash dispenser state change together or not at all
- The design must support adding new transaction types without modifying existing ones
- Security events (failed PIN attempts, lockouts) must be observable for logging and alerting
Core Entities
Card: carries the card number and the reference to the bank account. In a real system this would involve a network call to a card issuer, but in our model it’s a direct reference.
Account: holds the balance and the PIN hash. It exposes verify_pin, debit, and credit. It does not know about the ATM or any transaction type.
CashDispenser: knows what denominations are loaded and how many of each. It uses a greedy algorithm to figure out the minimum number of bills for any requested amount. It does not know about accounts.
Transaction: the abstract base class. Each transaction type (Withdraw, Deposit, Balance, Transfer) is a concrete subclass. This is the Strategy pattern: the ATM executes transaction.execute(account, dispenser) without caring which type it is.
ATM: the state machine and the orchestrator. It owns the current state, the card in the slot, the cash dispenser, and a reference to the bank for PIN verification.
The four ATM states are: IdleState (no card), CardInsertedState (card in, waiting for PIN), PinEnteredState (authenticated, ready for transaction), TransactionState (transaction in progress).
Class Design
+-----------------------+ +-------------------------+
| ATM |------>| ATMState |
|-----------------------| |-------------------------|
| - state | | + insert_card(card) |
| - card: Card | | + enter_pin(pin) |
| - dispenser: Dispenser| | + select_transaction(t) |
| - session: Session | | + cancel() |
+-----------------------+ +-------------------------+
| | |
Idle CardIn PinIn InTxn
+-----------------------+ +-------------------------+
| Account | | CashDispenser |
|-----------------------| |-------------------------|
| - balance: int | | - denominations: dict |
| - pin_hash: str | |-------------------------|
| - failed_attempts: int| | + can_dispense(amt) |
| - is_locked: bool | | + dispense(amt) |
|-----------------------| | + load(denom, count) |
| + verify_pin(pin)->bool| +-------------------------+
| + debit(amount) |
| + credit(amount) | +-------------------------+
+-----------------------+ | Transaction |
|-------------------------|
| + execute(acct,disp) |
+-------------------------+
| | | |
Withdraw Deposit Balance Transfer
Session is a lightweight object that lives for the duration of a card insertion. It tracks the authenticated account, the number of failed PIN attempts, and a log of transactions executed. Keeping this separate from ATM means the ATM itself has no mutable per-user state. When the session ends, the ATM drops the session object and goes back to idle cleanly.
Key Implementation
from __future__ import annotations
import hashlib
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Optional
# --- Domain objects ---
@dataclass
class Card:
card_number: str
account: Account # direct reference for simplicity; would be a lookup in reality
class Account:
MAX_FAILED_ATTEMPTS = 3
def __init__(self, account_id: str, pin: str, balance: int = 0) -> None:
self.account_id = account_id
self._pin_hash = hashlib.sha256(pin.encode()).hexdigest()
self._balance = balance
self._failed_attempts = 0
self.is_locked = False
def verify_pin(self, pin: str) -> bool:
if self.is_locked:
return False
if hashlib.sha256(pin.encode()).hexdigest() == self._pin_hash:
self._failed_attempts = 0
return True
self._failed_attempts += 1
if self._failed_attempts >= self.MAX_FAILED_ATTEMPTS:
self.is_locked = True
return False
def balance(self) -> int:
return self._balance
def debit(self, amount: int) -> None:
if amount > self._balance:
raise ValueError("Insufficient funds.")
self._balance -= amount
def credit(self, amount: int) -> None:
self._balance += amount
class CashDispenser:
"""
Tracks bill counts per denomination.
Greedy approach works because ATM denominations are always
divisible in a way that greedy is optimal (e.g., 100, 50, 20, 10).
"""
def __init__(self) -> None:
# denomination -> count, sorted descending by denomination
self._stock: dict[int, int] = {}
def load(self, denomination: int, count: int) -> None:
self._stock[denomination] = self._stock.get(denomination, 0) + count
def can_dispense(self, amount: int) -> bool:
remaining = amount
for denom in sorted(self._stock.keys(), reverse=True):
bills_needed = min(remaining // denom, self._stock[denom])
remaining -= bills_needed * denom
return remaining == 0
def dispense(self, amount: int) -> dict[int, int]:
if not self.can_dispense(amount):
raise ValueError(f"Cannot dispense exactly {amount}c from available bills.")
dispensed: dict[int, int] = {}
remaining = amount
for denom in sorted(self._stock.keys(), reverse=True):
if remaining == 0:
break
bills_needed = min(remaining // denom, self._stock[denom])
if bills_needed > 0:
dispensed[denom] = bills_needed
self._stock[denom] -= bills_needed
remaining -= bills_needed * denom
return dispensed
# --- Transaction hierarchy (Strategy pattern) ---
class Transaction(ABC):
@abstractmethod
def execute(self, account: Account, dispenser: CashDispenser) -> str:
"""Returns a human-readable result summary."""
pass
class WithdrawTransaction(Transaction):
def __init__(self, amount: int) -> None:
self._amount = amount
def execute(self, account: Account, dispenser: CashDispenser) -> str:
# Check both conditions before mutating either. Atomicity at the method level.
if account.balance() < self._amount:
raise ValueError("Insufficient funds.")
if not dispenser.can_dispense(self._amount):
raise ValueError("ATM cannot dispense that exact amount.")
# Both checks passed. Now mutate.
account.debit(self._amount)
bills = dispenser.dispense(self._amount)
bill_summary = ", ".join(f"{count}x${denom//100}" for denom, count in bills.items())
return f"Dispensed ${self._amount // 100}: {bill_summary}"
class DepositTransaction(Transaction):
def __init__(self, amount: int) -> None:
self._amount = amount
def execute(self, account: Account, dispenser: CashDispenser) -> str:
account.credit(self._amount)
return f"Deposited ${self._amount // 100}. New balance: ${account.balance() // 100}"
class BalanceTransaction(Transaction):
def execute(self, account: Account, dispenser: CashDispenser) -> str:
return f"Available balance: ${account.balance() // 100}"
class TransferTransaction(Transaction):
def __init__(self, target_account: Account, amount: int) -> None:
self._target = target_account
self._amount = amount
def execute(self, account: Account, dispenser: CashDispenser) -> str:
if account.balance() < self._amount:
raise ValueError("Insufficient funds for transfer.")
account.debit(self._amount)
self._target.credit(self._amount)
return f"Transferred ${self._amount // 100}."
# --- Session ---
@dataclass
class Session:
card: Card
transaction_log: list[str] = field(default_factory=list)
# --- ATM State Machine ---
class ATMState(ABC):
@abstractmethod
def insert_card(self, atm: ATM, card: Card) -> None:
pass
@abstractmethod
def enter_pin(self, atm: ATM, pin: str) -> None:
pass
@abstractmethod
def select_transaction(self, atm: ATM, txn: Transaction) -> None:
pass
@abstractmethod
def cancel(self, atm: ATM) -> None:
pass
class IdleState(ATMState):
def insert_card(self, atm: ATM, card: Card) -> None:
atm.session = Session(card=card)
atm.set_state(CardInsertedState())
print("Card accepted. Please enter your PIN.")
def enter_pin(self, atm: ATM, pin: str) -> None:
print("Please insert a card first.")
def select_transaction(self, atm: ATM, txn: Transaction) -> None:
print("Please insert a card first.")
def cancel(self, atm: ATM) -> None:
print("No active session.")
class CardInsertedState(ATMState):
def insert_card(self, atm: ATM, card: Card) -> None:
print("A card is already inserted.")
def enter_pin(self, atm: ATM, pin: str) -> None:
account = atm.session.card.account
if account.is_locked:
print("Account is locked. Please contact your bank.")
atm._eject_card()
return
if account.verify_pin(pin):
atm.set_state(PinEnteredState())
print("PIN accepted.")
else:
remaining = Account.MAX_FAILED_ATTEMPTS - account._failed_attempts
if account.is_locked:
print("Too many failed attempts. Card retained.")
atm._eject_card()
else:
print(f"Incorrect PIN. {remaining} attempt(s) remaining.")
def select_transaction(self, atm: ATM, txn: Transaction) -> None:
print("Please enter your PIN first.")
def cancel(self, atm: ATM) -> None:
atm._eject_card()
class PinEnteredState(ATMState):
def insert_card(self, atm: ATM, card: Card) -> None:
print("A session is already active.")
def enter_pin(self, atm: ATM, pin: str) -> None:
print("PIN already accepted.")
def select_transaction(self, atm: ATM, txn: Transaction) -> None:
account = atm.session.card.account
try:
result = txn.execute(account, atm.dispenser)
atm.session.transaction_log.append(result)
print(result)
# Notify observers (e.g., audit logger, fraud alert)
atm._notify(event="TRANSACTION_COMPLETE", detail=result)
except ValueError as e:
print(f"Transaction failed: {e}")
atm._notify(event="TRANSACTION_FAILED", detail=str(e))
# Return to PIN-entered state. User can do another transaction
# without reinserting the card
def cancel(self, atm: ATM) -> None:
atm._eject_card()
class ATM:
def __init__(self, dispenser: CashDispenser) -> None:
self.dispenser = dispenser
self.session: Optional[Session] = None
self._state: ATMState = IdleState()
self._observers: list[ATMObserver] = []
def set_state(self, state: ATMState) -> None:
self._state = state
def add_observer(self, observer: ATMObserver) -> None:
self._observers.append(observer)
def _notify(self, event: str, detail: str) -> None:
for obs in self._observers:
obs.on_event(event, detail, self.session)
def _eject_card(self) -> None:
print("Card ejected. Session ended.")
self.session = None
self.set_state(IdleState())
def insert_card(self, card: Card) -> None:
self._state.insert_card(self, card)
def enter_pin(self, pin: str) -> None:
self._state.enter_pin(self, pin)
def select_transaction(self, txn: Transaction) -> None:
self._state.select_transaction(self, txn)
def cancel(self) -> None:
self._state.cancel(self)
# --- Observer for audit and alerts ---
class ATMObserver(ABC):
@abstractmethod
def on_event(self, event: str, detail: str, session: Optional[Session]) -> None:
pass
class AuditLogger(ATMObserver):
def on_event(self, event: str, detail: str, session: Optional[Session]) -> None:
card_num = session.card.card_number if session else "N/A"
print(f"[AUDIT] {event} | card={card_num} | {detail}")
Why Transaction Atomicity Matters Even in LLD
The most common mistake in a first-pass ATM design is splitting the cash dispenser update and the account debit into separate calls that each succeed or fail independently. The WithdrawTransaction.execute method above checks both preconditions before mutating either object. That’s the key discipline. If the dispenser cannot produce the exact amount, we reject the whole operation before touching the account. If the account lacks funds, we reject before touching the dispenser.
In a real distributed system, atomicity across a database write and a physical device is much harder and requires compensating transactions or hardware integration. In the LLD model, the discipline is the same, just simpler to enforce: validate all preconditions in one pass, then apply all mutations in one pass. Never interleave.
The Observer Pattern for Security Events
The ATMObserver attachment point in ATM is intentional. Security events (failed PIN attempts, lockouts, large withdrawals) should be observable without the ATM itself needing to know who’s listening. An AuditLogger logs to disk. A FraudDetector fires if the same card has three failed PINs within a minute. A NotificationService texts the cardholder. All of these plug in as observers. The ATM calls _notify(event, detail) and does not care which observers are registered.
This matters because the set of things that need to react to ATM events will grow over time. If the ATM called audit_logger.log(...) directly, adding a fraud alert means changing the ATM class. With observers, adding a new concern is adding a new observer, nothing else.
Design Decisions and Trade-offs
Storing Account directly on Card vs. a bank network lookup. In a real system, the card stores only the card number and the bank issues a session token after authentication. The direct reference here simplifies the model but collapses the bank/ATM boundary. If this were a full system design, the ATM would POST to a bank API after PIN entry and receive an ephemeral token. All subsequent operations would use that token, not a direct object reference. The LLD pattern is the same, just with an HTTP client in the middle.
Greedy denomination algorithm. The greedy approach works for standard ATM denominations (100, 50, 20, 10) because they form a canonical coin system. It would fail for arbitrary denominations (for example, denominations of 6 and 4 can’t make 9 greedily from those two, but 1+4+4 works). If the design required arbitrary denominations, a dynamic programming solution would be necessary. Name this assumption explicitly in an interview.
_failed_attempts as a raw int on Account. This is a leaky abstraction. The logic for what constitutes a lockout condition lives in verify_pin, but the raw counter is publicly accessible. A cleaner design exposes only is_locked and failed_attempts_remaining() from Account, hiding the raw int. I left it exposed here to keep the PIN checking code in CardInsertedState readable, but it’s the kind of thing a reviewer would flag.
If any part of this design sparked a question or you’d model the transaction boundary differently, I’d like to hear your reasoning. Reach out on Twitter or LinkedIn.
Tags:
Related Posts
Design a Vending Machine: State Machines in Practice
A first-principles LLD walkthrough of a vending machine using the State pattern. Covers state transitions, inventory management, payment handling, and why State beats if/else chains when behavior must vary by state.
Design a Logging Library Like log4j: Filters, Appenders, and Formatters
A first-principles LLD walkthrough of a logging library covering logger hierarchy, log levels, appenders, formatters, and the Chain of Responsibility pattern for filter chains. Why appenders must be independent of formatters.
Design a Parking Lot: The Classic LLD Interview Problem, Reasoned Through
A first-principles LLD walkthrough of a parking lot system: spot types, vehicle matching, ticket lifecycle, pricing strategies, and how to handle concurrent spot allocation correctly.