ATM
State pattern for ATM lifecycle management, Chain of Responsibility for greedy bill dispensing across denominations, and clean separation between authentication, transaction, and cash handling concerns.
Key Abstractions
Context class that delegates all operations to its current state
Interface defining valid operations: insertCard, enterPin, withdraw, ejectCard
Waiting for card insertion. Rejects pin entry and withdrawal.
Card present, awaiting PIN. Rejects withdrawal until authenticated.
PIN verified. Allows withdrawal and card ejection.
Chain of Responsibility handler for denomination-based bill dispensing
Holds balance, validates PIN, processes debits
Immutable record of a completed withdrawal
Class Diagram
What This Is Really About
An ATM looks simple from the outside: stick in a card, punch in a PIN, grab your cash. But from a design perspective, it's two patterns working together. The State pattern manages what operations are valid at any given moment. Chain of Responsibility handles the physical act of counting out bills.
Without the State pattern, you'd have every method in the ATM littered with checks like "is a card inserted?" and "has the user authenticated?" Miss one check, and you've got a security hole where someone can withdraw cash without entering a PIN. State makes those illegal transitions structurally impossible.
Requirements
Functional
- Accept a bank card and authenticate via PIN with limited retry attempts
- Process cash withdrawals by debiting the linked account
- Dispense bills using the largest denominations first to minimize bill count
- Eject the card and return to an idle state
- Reject invalid operations based on current ATM state
Non-Functional
- Thread-safe account debits and cash dispenser operations
- PIN stored as a hash, never in plaintext
- ATM must recover gracefully from failed transactions (refund dispensed bills on debit failure)
Design Decisions
Why State pattern over if/else on an enum?
Consider what happens with an enum approach. Every method in the ATM class needs a switch statement checking the current state. withdraw() checks state, enterPin() checks state, ejectCard() checks state. Add a new state (say, "MaintenanceMode") and you need to update every single switch. With the State pattern, each state class only handles its own valid operations. Invalid operations throw immediately. New states are self-contained.
Why Chain of Responsibility for dispensing?
Bill dispensing is a greedy algorithm: use as many $100 bills as possible, then $50, then $20, then $10. Each denomination handler takes what it can and passes the remainder to the next handler. Adding a new denomination ($5 bills, for instance) means inserting one new handler into the chain. No existing handler changes. Compare that to a single method with nested if/else for each denomination.
Why dispense before debiting?
This feels backwards, but there's a good reason. If you debit first and then discover the ATM can't make change for $35 (it only has $50 and $20 bills), you've already taken the customer's money. By checking dispensability first, you fail fast without touching the account. If the debit then fails (insufficient funds), you refund the physical bills back to the dispenser.
Why hash the PIN?
Even in a simulation, storing PINs as plaintext is a bad habit. In production, the PIN would be validated by the bank's backend, not locally. But modeling it as a hash demonstrates the security mindset interviewers expect.
Interview Follow-ups
- "How would you handle deposits?" Add a DepositState that accepts cash/checks, validates the deposit, and credits the account. The state machine gets a new node, but existing states don't change.
- "What about daily withdrawal limits?" Add a WithdrawalPolicy that tracks daily totals per card. The AuthenticatedState checks the policy before dispensing.
- "How would you handle multiple currencies?" Each CashDispenser chain serves one currency. The ATM selects the right chain based on the card's currency or user preference.
- "What if the ATM runs low on a denomination mid-transaction?" The chain naturally handles this. If the $100 handler only has 1 bill left, it dispenses one and passes the rest to the $50 handler. If no combination works, the dispense fails before any debit.
Code Implementation
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from decimal import Decimal
from datetime import datetime
from enum import Enum
import uuid
import hashlib
import threading
class ATMError(Exception):
pass
class InvalidStateError(ATMError):
pass
class InsufficientFundsError(ATMError):
pass
class AuthenticationError(ATMError):
pass
class DispenseError(ATMError):
pass
@dataclass
class Account:
account_id: str
_balance: Decimal
_pin_hash: str
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
@staticmethod
def hash_pin(pin: str) -> str:
return hashlib.sha256(pin.encode()).hexdigest()
def validate_pin(self, pin: str) -> bool:
return self.hash_pin(pin) == self._pin_hash
def debit(self, amount: Decimal) -> None:
with self._lock:
if amount > self._balance:
raise InsufficientFundsError(
f"Balance {self._balance}, requested {amount}"
)
self._balance -= amount
@property
def balance(self) -> Decimal:
return self._balance
@dataclass(frozen=True)
class Card:
card_number: str
account: Account
@dataclass(frozen=True)
class Transaction:
id: str
card_number: str
amount: Decimal
denominations: dict[int, int]
timestamp: datetime
class CashDispenser:
"""Chain of Responsibility for bill dispensing. Greedy largest-first."""
def __init__(self, denomination: int, count: int):
self._denomination = denomination
self._count = count
self._next: CashDispenser | None = None
self._lock = threading.Lock()
def set_next(self, handler: "CashDispenser") -> "CashDispenser":
self._next = handler
return handler
def dispense(self, amount: int) -> dict[int, int]:
result: dict[int, int] = {}
self._dispense_recursive(amount, result)
dispensed_total = sum(d * c for d, c in result.items())
if dispensed_total != amount:
raise DispenseError(
f"Cannot dispense exactly ${amount} with available denominations"
)
return result
def _dispense_recursive(self, amount: int, result: dict[int, int]) -> int:
with self._lock:
bills_needed = amount // self._denomination
bills_used = min(bills_needed, self._count)
if bills_used > 0:
result[self._denomination] = bills_used
self._count -= bills_used
amount -= bills_used * self._denomination
if amount > 0 and self._next:
amount = self._next._dispense_recursive(amount, result)
return amount
def refund(self, denominations: dict[int, int]) -> None:
if self._denomination in denominations:
with self._lock:
self._count += denominations[self._denomination]
if self._next:
self._next.refund(denominations)
class ATMState(ABC):
@abstractmethod
def insert_card(self, atm: "ATM", card: Card) -> None: ...
@abstractmethod
def enter_pin(self, atm: "ATM", pin: str) -> None: ...
@abstractmethod
def withdraw(self, atm: "ATM", amount: int) -> Transaction: ...
@abstractmethod
def eject_card(self, atm: "ATM") -> None: ...
class IdleState(ATMState):
def insert_card(self, atm: "ATM", card: Card) -> None:
atm._current_card = card
atm._set_state(CardInsertedState())
print(f" Card {card.card_number} inserted.")
def enter_pin(self, atm: "ATM", pin: str) -> None:
raise InvalidStateError("Insert a card first.")
def withdraw(self, atm: "ATM", amount: int) -> Transaction:
raise InvalidStateError("Insert a card first.")
def eject_card(self, atm: "ATM") -> None:
raise InvalidStateError("No card to eject.")
class CardInsertedState(ATMState):
def __init__(self):
self._attempts = 0
self._max_attempts = 3
def insert_card(self, atm: "ATM", card: Card) -> None:
raise InvalidStateError("A card is already inserted.")
def enter_pin(self, atm: "ATM", pin: str) -> None:
card = atm._current_card
if card.account.validate_pin(pin):
atm._set_state(AuthenticatedState())
print(" PIN accepted. You are now authenticated.")
else:
self._attempts += 1
remaining = self._max_attempts - self._attempts
if remaining <= 0:
print(" Too many failed attempts. Ejecting card.")
atm._current_card = None
atm._set_state(IdleState())
else:
raise AuthenticationError(
f"Wrong PIN. {remaining} attempts remaining."
)
def withdraw(self, atm: "ATM", amount: int) -> Transaction:
raise InvalidStateError("Enter your PIN first.")
def eject_card(self, atm: "ATM") -> None:
print(f" Card {atm._current_card.card_number} ejected.")
atm._current_card = None
atm._set_state(IdleState())
class AuthenticatedState(ATMState):
def insert_card(self, atm: "ATM", card: Card) -> None:
raise InvalidStateError("A card is already inserted.")
def enter_pin(self, atm: "ATM", pin: str) -> None:
raise InvalidStateError("Already authenticated.")
def withdraw(self, atm: "ATM", amount: int) -> Transaction:
account = atm._current_card.account
# Try to dispense bills first, so we fail before debiting
try:
denominations = atm._dispenser.dispense(amount)
except DispenseError as e:
raise DispenseError(str(e))
# Debit the account. If it fails, refund the physical bills.
try:
account.debit(Decimal(amount))
except InsufficientFundsError:
atm._dispenser.refund(denominations)
raise
txn = Transaction(
id=str(uuid.uuid4())[:8],
card_number=atm._current_card.card_number,
amount=Decimal(amount),
denominations=denominations,
timestamp=datetime.now(),
)
atm._transactions.append(txn)
print(f" Dispensed ${amount}: {denominations}")
return txn
def eject_card(self, atm: "ATM") -> None:
print(f" Card {atm._current_card.card_number} ejected.")
atm._current_card = None
atm._set_state(IdleState())
class ATM:
def __init__(self, dispenser: CashDispenser):
self._state: ATMState = IdleState()
self._dispenser = dispenser
self._current_card: Card | None = None
self._transactions: list[Transaction] = []
def _set_state(self, state: ATMState) -> None:
self._state = state
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 withdraw(self, amount: int) -> Transaction:
return self._state.withdraw(self, amount)
def eject_card(self) -> None:
self._state.eject_card(self)
def build_dispenser() -> CashDispenser:
"""Build a chain: $100 -> $50 -> $20 -> $10"""
hundreds = CashDispenser(100, 10)
fifties = CashDispenser(50, 20)
twenties = CashDispenser(20, 30)
tens = CashDispenser(10, 50)
hundreds.set_next(fifties)
fifties.set_next(twenties)
twenties.set_next(tens)
return hundreds
if __name__ == "__main__":
dispenser = build_dispenser()
atm = ATM(dispenser)
# Set up an account with a known PIN
account = Account("ACC-001", Decimal("1500.00"), Account.hash_pin("1234"))
card = Card("4111-1111-1111-1111", account)
print("=== ATM Session ===\n")
# Insert card
print("1. Inserting card...")
atm.insert_card(card)
# Wrong PIN attempt
print("\n2. Entering wrong PIN...")
try:
atm.enter_pin("0000")
except AuthenticationError as e:
print(f" Error: {e}")
# Correct PIN
print("\n3. Entering correct PIN...")
atm.enter_pin("1234")
# Withdraw $280 (should get 2x$100 + 1x$50 + 1x$20 + 1x$10)
print("\n4. Withdrawing $280...")
txn = atm.withdraw(280)
print(f" Transaction ID: {txn.id}")
print(f" Account balance: ${account.balance}")
# Withdraw $150
print("\n5. Withdrawing $150...")
txn2 = atm.withdraw(150)
print(f" Account balance: ${account.balance}")
# Eject card
print("\n6. Ejecting card...")
atm.eject_card()
# Try to withdraw after ejection
print("\n7. Attempting withdrawal without card...")
try:
atm.withdraw(100)
except InvalidStateError as e:
print(f" Error: {e}")
print(f"\nTotal transactions: {len(atm._transactions)}")Common Mistakes
- ✗Using if/else chains on an enum instead of the State pattern. You end up with every method checking state, and forgetting one check means dispensing cash without a PIN.
- ✗Not validating withdrawal amount against available denominations. $35 can't be dispensed with $20 and $10 bills alone.
- ✗Forgetting to reset ATM state after card ejection or transaction failure. The machine gets stuck in an invalid state.
- ✗Dispensing without checking if the ATM's physical cash supply covers the withdrawal. Balance check alone isn't enough.
Key Points
- ✓State pattern makes invalid operations per state a compile-time concern, not a runtime if/else maze
- ✓Chain of Responsibility for dispensing: $100 -> $50 -> $20 -> $10. Greedy largest-first minimizes bill count.
- ✓Each state only implements the operations valid for that state. Everything else throws an explicit error.
- ✓Adding a new denomination means adding one handler to the chain. No existing code changes.