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:

#lld #machine-coding #object-oriented-design #python #transactions

Related Posts

Let's Connect! 💬

Whether you're looking to hire, want to collaborate on a project, or just want to chat about tech—I'd love to hear from you!