Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Difficulty: 🟢 Beginner-Intermediate | Time: 45 minutes | Patterns: State, Chain of Responsibility, Singleton

🎯 Problem Statement

Design an ATM system that can:
  • Authenticate users with card and PIN
  • Check account balance
  • Withdraw and deposit cash
  • Transfer funds between accounts
  • Handle multiple transaction types
  • Manage cash dispensing units
Why This Problem? ATM is THE showcase for the State Pattern. The ATM behaves differently based on its current state (idle, card inserted, authenticated, etc.). Without the State pattern, you end up with massive if/elif chains checking the current state in every method — a maintenance nightmare. With it, each state is a self-contained class that knows exactly which actions are valid and how to transition. This problem also tests your ability to model hardware components as software objects (CardReader, CashDispenser) — a skill that transfers directly to IoT, embedded systems, and device-driver design.

📋 Step 1: Clarify Requirements

Interview Tip: ATM involves both hardware components and banking logic. Clarify the focus!

Questions to Ask the Interviewer

CategoryQuestionImpact on Design
HardwareModel card reader, keypad, cash dispenser?Component classes needed
TransactionsWhich types? Withdraw, deposit, transfer?Transaction class hierarchy
SecurityPIN attempt limits? Card retention?Security logic
CashTrack denominations? Optimal dispensing?Chain of Responsibility
LimitsDaily withdrawal limits?Validation rules
NetworkOnline/offline mode?Bank connectivity

Functional Requirements

  • Insert card and authenticate with PIN (max 3 attempts)
  • Display account balance
  • Withdraw cash (with denomination selection)
  • Deposit cash/checks
  • Transfer between accounts
  • Print receipts
  • Handle card retention after failed attempts

Non-Functional Requirements

  • Secure transactions (encrypted PIN)
  • Handle hardware failures gracefully
  • Maintain transaction logs for audit
  • Support multiple currencies

🧩 Step 2: Identify Core Objects

Key Insight: The ATM uses State Pattern where each state (Idle, CardInserted, Authenticated, etc.) handles user actions differently. Invalid actions for a state can be rejected cleanly.

Hardware

ATM, CardReader, CashDispenser, KeyPad

Banking

Account, Card, Bank, Transaction

Operations

ATMState, Withdrawal, Deposit, Transfer

Entity-Responsibility Mapping

EntityResponsibilitiesPattern
ATMCoordinate components, manage stateContext (State Pattern)
ATMStateDefine behavior for each stateState Pattern
CashDispenserDispense bills in optimal denominationsChain of Responsibility
TransactionEncapsulate transaction logicCommand Pattern
BankVerify cards, process transactionsSingleton

State Transition Diagram

┌─────────┐  insertCard   ┌──────────────┐  validPIN   ┌───────────────┐
│  IDLE   │──────────────►│ CARD_INSERTED│─────────────►│ AUTHENTICATED │
└─────────┘               └──────────────┘              └───────────────┘
     ▲                           │                             │
     │                      invalidPIN (3x)                    │
     │                           │                    selectTransaction
     │                           ▼                             ▼
     │                    ┌──────────────┐            ┌───────────────┐
     │                    │ CARD_RETAINED│            │  TRANSACTION  │
     │                    └──────────────┘            │   PROCESSING  │
     │                                                └───────────────┘
     │                                                         │
     │                        cancel/eject                     │
     └─────────────────────────────────────────────────────────┘

📐 Step 3: Class Diagram

┌─────────────────────────────────────────────────────────────┐
│                           ATM                               │
├─────────────────────────────────────────────────────────────┤
│ - id: String                                                │
│ - location: Address                                         │
│ - state: ATMState                                           │
│ - cardReader: CardReader                                    │
│ - cashDispenser: CashDispenser                              │
│ - keypad: Keypad                                            │
│ - screen: Screen                                            │
│ - currentSession: Session                                   │
├─────────────────────────────────────────────────────────────┤
│ + insertCard(card): void                                    │
│ + authenticatePin(pin): bool                                │
│ + getBalance(): Decimal                                     │
│ + withdraw(amount): bool                                    │
│ + deposit(amount): bool                                     │
│ + transfer(toAccount, amount): bool                         │
│ + ejectCard(): void                                         │
└─────────────────────────────────────────────────────────────┘

            ┌─────────────────┼─────────────────┐
            ▼                 ▼                 ▼
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
│    CardReader     │ │  CashDispenser    │ │      Screen       │
├───────────────────┤ ├───────────────────┤ ├───────────────────┤
│ + readCard()      │ │ - cashBins: Map   │ │ + display(msg)    │
│ + ejectCard()     │ │ + dispense(amt)   │ │ + getInput()      │
│ + retainCard()    │ │ + getBalance()    │ │ + showMenu()      │
└───────────────────┘ │ + acceptCash()    │ └───────────────────┘
                      └───────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                       <<abstract>>                          │
│                        ATMState                             │
├─────────────────────────────────────────────────────────────┤
│ + insertCard(atm, card): void                              │
│ + authenticatePin(atm, pin): void                          │
│ + selectTransaction(atm, type): void                       │
│ + executeTransaction(atm): void                            │
│ + cancel(atm): void                                        │
└─────────────────────────────────────────────────────────────┘

   ┌────────┼────────┬────────────────┬────────────────┐
   │        │        │                │                │
┌──┴───┐ ┌──┴───┐ ┌──┴─────────┐ ┌────┴────┐ ┌────────┴────┐
│ Idle │ │ Card │ │Transaction │ │ Process │ │ Out of      │
│State │ │Insert│ │ Select     │ │  ing    │ │ Service     │
└──────┘ └──────┘ └────────────┘ └─────────┘ └─────────────┘

Step 4: Implementation

Enums and Constants

from enum import Enum
from datetime import datetime
from typing import Optional, List, Dict
from dataclasses import dataclass, field
from decimal import Decimal
from abc import ABC, abstractmethod
import uuid
import threading

class TransactionType(Enum):
    BALANCE_INQUIRY = "balance"
    WITHDRAWAL = "withdrawal"
    DEPOSIT = "deposit"
    TRANSFER = "transfer"
    PIN_CHANGE = "pin_change"

class TransactionStatus(Enum):
    PENDING = "pending"
    COMPLETED = "completed"
    FAILED = "failed"
    CANCELLED = "cancelled"

class CardStatus(Enum):
    ACTIVE = "active"
    BLOCKED = "blocked"
    EXPIRED = "expired"

# ATM Configuration
MAX_PIN_ATTEMPTS = 3
WITHDRAWAL_LIMIT_DAILY = Decimal("1000.00")
MIN_WITHDRAWAL = Decimal("20.00")
MAX_WITHDRAWAL = Decimal("500.00")

# Available denominations
DENOMINATIONS = [100, 50, 20, 10]

Account and Card Classes

@dataclass
class Account:
    account_number: str
    holder_name: str
    balance: Decimal = Decimal("0.00")
    account_type: str = "checking"  # checking, savings
    daily_withdrawn: Decimal = Decimal("0.00")
    last_withdrawal_date: Optional[datetime] = None
    
    def can_withdraw(self, amount: Decimal) -> bool:
        # Reset daily limit if new day
        today = datetime.now().date()
        if self.last_withdrawal_date and self.last_withdrawal_date.date() != today:
            self.daily_withdrawn = Decimal("0.00")
        
        if amount > self.balance:
            return False
        if self.daily_withdrawn + amount > WITHDRAWAL_LIMIT_DAILY:
            return False
        return True
    
    def withdraw(self, amount: Decimal) -> bool:
        if not self.can_withdraw(amount):
            return False
        
        self.balance -= amount
        self.daily_withdrawn += amount
        self.last_withdrawal_date = datetime.now()
        return True
    
    def deposit(self, amount: Decimal) -> bool:
        if amount <= 0:
            return False
        self.balance += amount
        return True
    
    def transfer_to(self, target: 'Account', amount: Decimal) -> bool:
        if amount > self.balance:
            return False
        
        self.balance -= amount
        target.balance += amount
        return True


class Card:
    def __init__(
        self,
        card_number: str,
        account: Account,
        pin: str,
        expiry_date: datetime
    ):
        self.card_number = card_number
        self.account = account
        self._pin_hash = self._hash_pin(pin)
        self.expiry_date = expiry_date
        self.status = CardStatus.ACTIVE
        self.failed_attempts = 0
    
    def _hash_pin(self, pin: str) -> str:
        # In reality, use proper hashing like bcrypt
        import hashlib
        return hashlib.sha256(pin.encode()).hexdigest()
    
    def verify_pin(self, pin: str) -> bool:
        if self.status != CardStatus.ACTIVE:
            return False
        
        if self._hash_pin(pin) == self._pin_hash:
            self.failed_attempts = 0
            return True
        
        self.failed_attempts += 1
        if self.failed_attempts >= MAX_PIN_ATTEMPTS:
            self.status = CardStatus.BLOCKED
        return False
    
    def change_pin(self, old_pin: str, new_pin: str) -> bool:
        if not self.verify_pin(old_pin):
            return False
        
        if len(new_pin) != 4 or not new_pin.isdigit():
            return False
        
        self._pin_hash = self._hash_pin(new_pin)
        return True
    
    def is_expired(self) -> bool:
        return datetime.now() > self.expiry_date

Transaction Classes

@dataclass
class Transaction:
    id: str = field(default_factory=lambda: str(uuid.uuid4())[:12].upper())
    transaction_type: TransactionType = TransactionType.BALANCE_INQUIRY
    amount: Decimal = Decimal("0.00")
    source_account: Optional[str] = None
    target_account: Optional[str] = None
    timestamp: datetime = field(default_factory=datetime.now)
    status: TransactionStatus = TransactionStatus.PENDING
    atm_id: str = ""
    
    def complete(self):
        self.status = TransactionStatus.COMPLETED
    
    def fail(self):
        self.status = TransactionStatus.FAILED
    
    def __str__(self):
        return (f"[{self.id}] {self.transaction_type.value}: "
                f"${self.amount} - {self.status.value}")

Hardware Components

class CardReader:
    def __init__(self):
        self.card: Optional[Card] = None
        self._card_retained = False
    
    def read_card(self, card: Card) -> bool:
        if card.is_expired():
            print("Card expired!")
            return False
        
        if card.status == CardStatus.BLOCKED:
            print("Card is blocked!")
            self.retain_card()
            return False
        
        self.card = card
        print(f"Card {card.card_number[-4:]} inserted")
        return True
    
    def eject_card(self) -> Optional[Card]:
        if self._card_retained:
            print("Card has been retained!")
            return None
        
        card = self.card
        self.card = None
        print("Card ejected")
        return card
    
    def retain_card(self):
        """Retain card (too many failed attempts)"""
        self._card_retained = True
        print("Card has been retained by ATM. Please contact your bank.")


class CashBin:
    def __init__(self, denomination: int, initial_count: int = 100):
        self.denomination = denomination
        self.count = initial_count
    
    def can_dispense(self, num_notes: int) -> bool:
        return self.count >= num_notes
    
    def dispense(self, num_notes: int) -> bool:
        if not self.can_dispense(num_notes):
            return False
        self.count -= num_notes
        return True
    
    def add(self, num_notes: int):
        self.count += num_notes


class CashDispenser:
    def __init__(self):
        self.bins: Dict[int, CashBin] = {
            100: CashBin(100, 50),
            50: CashBin(50, 100),
            20: CashBin(20, 200),
            10: CashBin(10, 300)
        }
    
    def get_total_cash(self) -> Decimal:
        total = sum(
            bin.denomination * bin.count 
            for bin in self.bins.values()
        )
        return Decimal(str(total))
    
    def can_dispense(self, amount: Decimal) -> bool:
        """Check if amount can be dispensed with available notes"""
        if amount > self.get_total_cash():
            return False
        
        # Try to find a valid combination
        return self._find_dispense_combination(int(amount)) is not None
    
    def _find_dispense_combination(self, amount: int) -> Optional[Dict[int, int]]:
        """Find optimal note combination using greedy algorithm"""
        result = {}
        remaining = amount
        
        for denom in sorted(self.bins.keys(), reverse=True):
            if remaining <= 0:
                break
            
            notes_needed = remaining // denom
            notes_available = self.bins[denom].count
            notes_to_use = min(notes_needed, notes_available)
            
            if notes_to_use > 0:
                result[denom] = notes_to_use
                remaining -= denom * notes_to_use
        
        return result if remaining == 0 else None
    
    def dispense(self, amount: Decimal) -> Optional[Dict[int, int]]:
        """Dispense cash and return note breakdown"""
        combination = self._find_dispense_combination(int(amount))
        
        if not combination:
            print("Cannot dispense exact amount!")
            return None
        
        # Dispense notes
        for denom, count in combination.items():
            self.bins[denom].dispense(count)
        
        print(f"Dispensing ${amount}:")
        for denom, count in sorted(combination.items(), reverse=True):
            if count > 0:
                print(f"  {count} x ${denom}")
        
        return combination
    
    def accept_cash(self, notes: Dict[int, int]) -> Decimal:
        """Accept deposited cash"""
        total = Decimal("0.00")
        
        for denom, count in notes.items():
            if denom in self.bins:
                self.bins[denom].add(count)
                total += Decimal(str(denom * count))
        
        return total


class Screen:
    def display(self, message: str):
        print(f"\n{'='*40}")
        print(message)
        print('='*40)
    
    def show_menu(self, options: List[str]) -> int:
        self.display("Please select an option:")
        for i, option in enumerate(options, 1):
            print(f"  {i}. {option}")
        print()
        
        # In real ATM, would read from keypad
        # Here we simulate with input
        try:
            choice = int(input("Enter choice: "))
            return choice if 1 <= choice <= len(options) else -1
        except ValueError:
            return -1
    
    def get_amount(self) -> Optional[Decimal]:
        try:
            amount = input("Enter amount: $")
            return Decimal(amount)
        except:
            return None
    
    def print_receipt(self, transaction: Transaction, balance: Decimal):
        print("\n" + "="*40)
        print("           TRANSACTION RECEIPT")
        print("="*40)
        print(f"Date: {transaction.timestamp.strftime('%Y-%m-%d %H:%M')}")
        print(f"Transaction ID: {transaction.id}")
        print(f"Type: {transaction.transaction_type.value.upper()}")
        if transaction.amount > 0:
            print(f"Amount: ${transaction.amount}")
        print(f"Balance: ${balance}")
        print("="*40)
        print("Thank you for using our ATM!")
        print("="*40 + "\n")

ATM State Pattern

class ATMState(ABC):
    """State pattern for ATM operations"""
    
    @abstractmethod
    def insert_card(self, atm: 'ATM', card: Card):
        pass
    
    @abstractmethod
    def authenticate_pin(self, atm: 'ATM', pin: str):
        pass
    
    @abstractmethod
    def select_transaction(self, atm: 'ATM', trans_type: TransactionType):
        pass
    
    @abstractmethod
    def execute_transaction(self, atm: 'ATM', **kwargs):
        pass
    
    @abstractmethod
    def cancel(self, atm: 'ATM'):
        pass


class IdleState(ATMState):
    """ATM waiting for card"""
    
    def insert_card(self, atm: 'ATM', card: Card):
        if atm.card_reader.read_card(card):
            atm.current_card = card
            atm.state = CardInsertedState()
            atm.screen.display("Card accepted. Please enter your PIN.")
        else:
            atm.screen.display("Card not accepted.")
    
    def authenticate_pin(self, atm: 'ATM', pin: str):
        atm.screen.display("Please insert card first.")
    
    def select_transaction(self, atm: 'ATM', trans_type: TransactionType):
        atm.screen.display("Please insert card first.")
    
    def execute_transaction(self, atm: 'ATM', **kwargs):
        atm.screen.display("Please insert card first.")
    
    def cancel(self, atm: 'ATM'):
        atm.screen.display("No active session.")


class CardInsertedState(ATMState):
    """Card inserted, waiting for PIN"""
    
    def insert_card(self, atm: 'ATM', card: Card):
        atm.screen.display("Card already inserted.")
    
    def authenticate_pin(self, atm: 'ATM', pin: str):
        if atm.current_card.verify_pin(pin):
            atm.state = AuthenticatedState()
            atm.screen.display("PIN verified. Select transaction.")
        else:
            remaining = MAX_PIN_ATTEMPTS - atm.current_card.failed_attempts
            if remaining > 0:
                atm.screen.display(f"Incorrect PIN. {remaining} attempts remaining.")
            else:
                atm.card_reader.retain_card()
                atm.screen.display("Card retained. Contact your bank.")
                atm.state = IdleState()
    
    def select_transaction(self, atm: 'ATM', trans_type: TransactionType):
        atm.screen.display("Please enter PIN first.")
    
    def execute_transaction(self, atm: 'ATM', **kwargs):
        atm.screen.display("Please enter PIN first.")
    
    def cancel(self, atm: 'ATM'):
        atm.card_reader.eject_card()
        atm.current_card = None
        atm.state = IdleState()
        atm.screen.display("Transaction cancelled.")


class AuthenticatedState(ATMState):
    """User authenticated, can select transactions"""
    
    def insert_card(self, atm: 'ATM', card: Card):
        atm.screen.display("Session active. Complete or cancel first.")
    
    def authenticate_pin(self, atm: 'ATM', pin: str):
        atm.screen.display("Already authenticated.")
    
    def select_transaction(self, atm: 'ATM', trans_type: TransactionType):
        atm.current_transaction_type = trans_type
        atm.state = TransactionSelectedState()
        
        if trans_type == TransactionType.BALANCE_INQUIRY:
            atm.state.execute_transaction(atm)
        else:
            atm.screen.display(f"Selected: {trans_type.value}. Enter details.")
    
    def execute_transaction(self, atm: 'ATM', **kwargs):
        atm.screen.display("Please select transaction type first.")
    
    def cancel(self, atm: 'ATM'):
        atm.card_reader.eject_card()
        atm.current_card = None
        atm.current_transaction_type = None
        atm.state = IdleState()
        atm.screen.display("Session ended. Thank you!")


class TransactionSelectedState(ATMState):
    """Transaction type selected, ready to execute"""
    
    def insert_card(self, atm: 'ATM', card: Card):
        atm.screen.display("Session active.")
    
    def authenticate_pin(self, atm: 'ATM', pin: str):
        atm.screen.display("Already authenticated.")
    
    def select_transaction(self, atm: 'ATM', trans_type: TransactionType):
        atm.current_transaction_type = trans_type
        atm.screen.display(f"Changed to: {trans_type.value}")
    
    def execute_transaction(self, atm: 'ATM', **kwargs):
        trans_type = atm.current_transaction_type
        account = atm.current_card.account
        
        transaction = Transaction(
            transaction_type=trans_type,
            source_account=account.account_number,
            atm_id=atm.id
        )
        
        success = False
        
        if trans_type == TransactionType.BALANCE_INQUIRY:
            success = True
            atm.screen.display(f"Current Balance: ${account.balance}")
        
        elif trans_type == TransactionType.WITHDRAWAL:
            amount = kwargs.get('amount')
            if amount and atm._process_withdrawal(account, amount):
                transaction.amount = amount
                success = True
        
        elif trans_type == TransactionType.DEPOSIT:
            amount = kwargs.get('amount')
            if amount and atm._process_deposit(account, amount):
                transaction.amount = amount
                success = True
        
        elif trans_type == TransactionType.TRANSFER:
            amount = kwargs.get('amount')
            target = kwargs.get('target_account')
            if amount and target and atm._process_transfer(account, target, amount):
                transaction.amount = amount
                transaction.target_account = target.account_number
                success = True
        
        if success:
            transaction.complete()
            atm.transaction_log.append(transaction)
            atm.screen.print_receipt(transaction, account.balance)
        else:
            transaction.fail()
        
        # Return to authenticated state for another transaction
        atm.state = AuthenticatedState()
    
    def cancel(self, atm: 'ATM'):
        atm.current_transaction_type = None
        atm.state = AuthenticatedState()
        atm.screen.display("Transaction cancelled. Select another or exit.")


class OutOfServiceState(ATMState):
    """ATM out of service"""
    
    def insert_card(self, atm: 'ATM', card: Card):
        atm.screen.display("ATM out of service. Please use another ATM.")
    
    def authenticate_pin(self, atm: 'ATM', pin: str):
        atm.screen.display("ATM out of service.")
    
    def select_transaction(self, atm: 'ATM', trans_type: TransactionType):
        atm.screen.display("ATM out of service.")
    
    def execute_transaction(self, atm: 'ATM', **kwargs):
        atm.screen.display("ATM out of service.")
    
    def cancel(self, atm: 'ATM'):
        atm.screen.display("ATM out of service.")

ATM Main Class

class ATM:
    def __init__(self, atm_id: str, bank: 'Bank'):
        self.id = atm_id
        self.bank = bank
        
        # Hardware components
        self.card_reader = CardReader()
        self.cash_dispenser = CashDispenser()
        self.screen = Screen()
        
        # State management
        self.state: ATMState = IdleState()
        self.current_card: Optional[Card] = None
        self.current_transaction_type: Optional[TransactionType] = None
        
        # Transaction log
        self.transaction_log: List[Transaction] = []
        
        self._lock = threading.Lock()
    
    # Delegate to current state
    def insert_card(self, card: Card):
        self.state.insert_card(self, card)
    
    def authenticate_pin(self, pin: str):
        self.state.authenticate_pin(self, pin)
    
    def select_transaction(self, trans_type: TransactionType):
        self.state.select_transaction(self, trans_type)
    
    def execute_transaction(self, **kwargs):
        self.state.execute_transaction(self, **kwargs)
    
    def cancel(self):
        self.state.cancel(self)
    
    # Transaction processing methods
    def _process_withdrawal(self, account: Account, amount: Decimal) -> bool:
        with self._lock:
            # Validation
            if amount < MIN_WITHDRAWAL:
                self.screen.display(f"Minimum withdrawal is ${MIN_WITHDRAWAL}")
                return False
            
            if amount > MAX_WITHDRAWAL:
                self.screen.display(f"Maximum withdrawal is ${MAX_WITHDRAWAL}")
                return False
            
            if not self.cash_dispenser.can_dispense(amount):
                self.screen.display("Cannot dispense this amount. Try different amount.")
                return False
            
            if not account.can_withdraw(amount):
                self.screen.display("Insufficient funds or daily limit exceeded.")
                return False
            
            # Execute withdrawal
            if account.withdraw(amount):
                self.cash_dispenser.dispense(amount)
                self.screen.display(f"Please take your cash: ${amount}")
                return True
            
            return False
    
    def _process_deposit(self, account: Account, amount: Decimal) -> bool:
        if amount <= 0:
            self.screen.display("Invalid amount!")
            return False
        
        # In reality, would count deposited bills
        notes = {50: int(amount / 50)}  # Simplified
        deposited = self.cash_dispenser.accept_cash(notes)
        
        if account.deposit(deposited):
            self.screen.display(f"Deposited: ${deposited}")
            return True
        
        return False
    
    def _process_transfer(
        self, 
        from_account: Account, 
        to_account: Account, 
        amount: Decimal
    ) -> bool:
        if amount <= 0:
            self.screen.display("Invalid amount!")
            return False
        
        if from_account.transfer_to(to_account, amount):
            self.screen.display(f"Transferred ${amount} to {to_account.account_number}")
            return True
        
        self.screen.display("Transfer failed. Insufficient funds.")
        return False
    
    def check_cash_level(self) -> bool:
        """Check if ATM needs refilling"""
        total = self.cash_dispenser.get_total_cash()
        if total < Decimal("5000"):
            self.state = OutOfServiceState()
            return False
        return True
    
    def run_interactive(self):
        """Run ATM in interactive mode"""
        self.screen.display("Welcome to the ATM!")
        
        while True:
            if isinstance(self.state, IdleState):
                card_num = input("\nInsert card number (or 'quit'): ")
                if card_num.lower() == 'quit':
                    break
                
                card = self.bank.get_card(card_num)
                if card:
                    self.insert_card(card)
                else:
                    self.screen.display("Card not found!")
            
            elif isinstance(self.state, CardInsertedState):
                pin = input("Enter PIN: ")
                self.authenticate_pin(pin)
            
            elif isinstance(self.state, AuthenticatedState):
                options = ["Balance Inquiry", "Withdraw", "Deposit", "Transfer", "Exit"]
                choice = self.screen.show_menu(options)
                
                if choice == 1:
                    self.select_transaction(TransactionType.BALANCE_INQUIRY)
                elif choice == 2:
                    self.select_transaction(TransactionType.WITHDRAWAL)
                    amount = self.screen.get_amount()
                    if amount:
                        self.execute_transaction(amount=amount)
                elif choice == 3:
                    self.select_transaction(TransactionType.DEPOSIT)
                    amount = self.screen.get_amount()
                    if amount:
                        self.execute_transaction(amount=amount)
                elif choice == 4:
                    self.select_transaction(TransactionType.TRANSFER)
                    target_num = input("Enter target account: ")
                    target = self.bank.get_account(target_num)
                    if target:
                        amount = self.screen.get_amount()
                        if amount:
                            self.execute_transaction(amount=amount, target_account=target)
                elif choice == 5:
                    self.cancel()
            
            else:
                break


class Bank:
    """Simplified bank for demo purposes"""
    
    def __init__(self, name: str):
        self.name = name
        self.accounts: Dict[str, Account] = {}
        self.cards: Dict[str, Card] = {}
    
    def create_account(self, holder_name: str, initial_balance: Decimal) -> Account:
        account = Account(
            account_number=f"ACC{len(self.accounts)+1:06d}",
            holder_name=holder_name,
            balance=initial_balance
        )
        self.accounts[account.account_number] = account
        return account
    
    def issue_card(self, account: Account, pin: str) -> Card:
        card = Card(
            card_number=f"CARD{len(self.cards)+1:012d}",
            account=account,
            pin=pin,
            expiry_date=datetime(2027, 12, 31)
        )
        self.cards[card.card_number] = card
        return card
    
    def get_card(self, card_number: str) -> Optional[Card]:
        return self.cards.get(card_number)
    
    def get_account(self, account_number: str) -> Optional[Account]:
        return self.accounts.get(account_number)

Step 5: Usage Example

# Setup bank and accounts
bank = Bank("National Bank")

# Create accounts
account1 = bank.create_account("John Doe", Decimal("5000.00"))
account2 = bank.create_account("Jane Smith", Decimal("3000.00"))

# Issue cards
card1 = bank.issue_card(account1, "1234")
card2 = bank.issue_card(account2, "5678")

print(f"John's Card: {card1.card_number}")
print(f"Jane's Card: {card2.card_number}")

# Create ATM
atm = ATM("ATM001", bank)

# Simulate transactions
print("\n--- John's Session ---")

# Insert card
atm.insert_card(card1)

# Enter PIN
atm.authenticate_pin("1234")

# Check balance
atm.select_transaction(TransactionType.BALANCE_INQUIRY)

# Withdraw cash
atm.select_transaction(TransactionType.WITHDRAWAL)
atm.execute_transaction(amount=Decimal("200.00"))

# Transfer to Jane
atm.select_transaction(TransactionType.TRANSFER)
atm.execute_transaction(amount=Decimal("500.00"), target_account=account2)

# Exit
atm.cancel()

print(f"\nJohn's final balance: ${account1.balance}")
print(f"Jane's final balance: ${account2.balance}")

# Check ATM cash level
print(f"\nATM Cash: ${atm.cash_dispenser.get_total_cash()}")

Key Design Decisions

ATM has clear states (Idle, Card Inserted, Authenticated, etc.) with different valid actions in each. State pattern makes transitions explicit and prevents invalid operations. Without it, every method in the ATM class would start with if self.state == ... checks — imagine 5 states and 6 methods, that is 30 conditional branches to maintain. State pattern reduces this to focused, single-responsibility state classes. Each state class only contains logic relevant to that state, making the code self-documenting: looking at CardInsertedState tells you exactly what the ATM can do when a card is inserted.
Each component (CardReader, CashDispenser, Screen) has distinct responsibilities and could be swapped independently. This follows Single Responsibility Principle.
Greedy works well for standard denominations (100, 50, 20, 10) because these denominations are specifically designed to be greedy-friendly — each denomination is at least double the next smaller one. For arbitrary denominations (e.g., 1, 3, 4 trying to make 6), greedy fails and you need dynamic programming. In an interview, mention this trade-off: “Greedy is optimal for standard denominations and runs in O(D) where D is the number of denomination types. If we supported non-standard denominations, I would switch to DP.” This demonstrates algorithmic awareness within a design context.
Multiple processes could access the ATM simultaneously (hardware interrupts, network requests). Locking prevents race conditions on cash counts and account balances.

Extension Points

Interview Extensions - Be ready to discuss:
  • Multi-Account Cards: Support cards linked to multiple accounts
  • Mini Statement: Show last N transactions
  • Bill Payments: Pay utilities from ATM
  • Cardless Withdrawal: OTP-based withdrawal
  • Fraud Detection: Unusual patterns, geographic anomalies

Interview Deep-Dive Questions

Strong answer:
  • The ATM has well-defined states (Idle, CardInserted, Authenticated, TransactionSelected, OutOfService) where each state permits a completely different set of operations. The State pattern replaces a growing nest of if self.state == ... conditionals in every single method with self-contained state classes that encapsulate both the allowed behavior and the transition logic.
  • Without State, if you have 5 states and 6 methods, you are maintaining 30 conditional branches scattered across the ATM class. Adding a new state (e.g., MaintenanceMode) means touching every method in ATM. With State, you add one new class that implements the interface, and existing states are untouched — perfect Open/Closed Principle adherence.
  • Each state class also acts as living documentation: looking at CardInsertedState tells you exactly what is valid (enter PIN, cancel) and what is rejected (trying to withdraw). This makes code reviews and onboarding dramatically easier.
  • A key benefit in production is debuggability: you can log state transitions as first-class events, which makes it trivial to reconstruct what happened during an incident. “The ATM was in AuthenticatedState when it received a second card insertion” is much more useful than “flag X was true and flag Y was false.”
Red flag answer: “We use the State pattern because the ATM has states.” This answer lacks any explanation of the alternative, the trade-offs, or the concrete maintenance benefit. It suggests pattern-name-dropping without understanding the engineering motivation.Follow-ups:
  1. If an interviewer asked you to add a MaintenanceMode state where a technician can reload cash bins, how would you integrate it without breaking existing states?
  2. The current design creates a new state object on every transition (e.g., atm.state = CardInsertedState()). What is the memory/GC impact of this, and when would you switch to flyweight or singleton states instead?
Strong answer:
  • This is the classic partial-failure problem. The key insight is ordering the operations correctly: you should dispense cash first (the irreversible physical action), and only then commit the account debit. If dispensing fails, you have not touched the account, so there is nothing to roll back.
  • In the current code, account.withdraw(amount) is called before cash_dispenser.dispense(amount). This is actually the wrong order for a real ATM. If the debit succeeds but the dispenser jams, the customer loses money. Real ATMs use a two-phase approach: (1) place a “hold” on the funds (like a pending debit), (2) attempt physical dispensing, (3) if dispensing succeeds, finalize the debit; if it fails, release the hold and log the failure.
  • The _process_withdrawal method also wraps everything in self._lock, which prevents concurrent modifications but does not address the atomicity of the debit-then-dispense sequence itself. Thread safety and transaction atomicity are different concerns and are often confused.
  • In production, ATMs use Electronic Journal (EJ) logging, where every physical and logical event is recorded in a tamper-proof log. If there is a discrepancy, the journal is the source of truth for reconciliation. You would model this as a TransactionJournal that logs each step independently: hold placed, dispensing attempted, dispensing succeeded/failed, debit committed/released.
Red flag answer: “We just use a try/except around the withdrawal and rollback on failure.” This ignores the fundamental problem that physical cash dispensing is not a database operation you can roll back. It shows a lack of understanding of real-world failure modes.Follow-ups:
  1. How would you handle the scenario where the ATM dispenses cash successfully but the network call to finalize the debit at the bank fails?
  2. How do real banking systems reconcile end-of-day discrepancies between what the ATM’s journal says and what the bank’s ledger shows?
Strong answer:
  • A physical ATM is inherently single-user (one card slot, one session at a time), so true user-level concurrency is rare. However, concurrency does matter at the software level: hardware interrupts (card ejection during a transaction), network callbacks (bank responses arriving while the user cancels), and maintenance operations (a technician running diagnostics while a session is active) can all create concurrent access to shared state.
  • The current design uses threading.Lock() inside _process_withdrawal, which protects the cash dispenser and account balance from race conditions. But this lock is only on withdrawal — deposit and transfer are unprotected. This is a real bug: if a deposit and a withdrawal execute concurrently, the account.balance field could experience a data race.
  • The state machine itself is not thread-safe: self.state is mutated without locking. If a hardware interrupt triggers cancel() while execute_transaction() is mid-execution, the ATM could end up in an inconsistent state.
  • The fix is to make the top-level ATM methods (not just withdrawal) acquire the lock. Every public method (insert_card, authenticate_pin, select_transaction, execute_transaction, cancel) should be wrapped with self._lock. The state itself becomes the single-threaded serialization point.
Red flag answer: “ATMs only serve one person at a time so concurrency is not an issue.” This misses the software-level concurrency entirely — hardware interrupts, network callbacks, and maintenance threads are all real concurrent access vectors.Follow-ups:
  1. If you moved this ATM design into a distributed setting (e.g., a fleet of ATMs sharing account state via a central bank), what concurrency mechanism replaces the in-process lock?
  2. The current CashDispenser.dispense() method modifies bin counts without any locking. What specific race condition could occur, and how would you demonstrate it with a test?
Strong answer:
  • The greedy algorithm works by always picking the largest denomination first. For the standard denominations in this design (100, 50, 20, 10), greedy always produces the optimal result because each denomination is at least 2x the next smaller one — this is a property of canonical coin systems.
  • Greedy fails with non-canonical denominations. Classic example: denominations of [1, 3, 4] and target amount 6. Greedy picks 4 + 1 + 1 = three coins, but optimal is 3 + 3 = two coins. If an ATM supported unusual denominations (common in some currencies — e.g., the old Indian 2-rupee note), greedy would dispense suboptimal or even incorrect combinations.
  • The replacement is dynamic programming. You build a table where dp[i] represents the minimum number of notes needed to dispense amount i, and backtrack to find the actual combination. Time complexity goes from O(D) for greedy (where D is number of denominations) to O(amount * D) for DP, but for ATM-scale amounts this is negligible.
  • In production, there is a second consideration beyond optimality: cash bin balancing. A real ATM might deliberately avoid depleting the 100binevenwhengreedywouldpreferit,becauserunningoutof100 bin even when greedy would prefer it, because running out of 100 bills means the machine cannot serve large withdrawals at all. This becomes a constrained optimization problem where you minimize total notes dispensed subject to maintaining minimum bin levels.
Red flag answer: “Greedy always works for making change.” This is flatly wrong and shows a gap in algorithm fundamentals. Even if the candidate correctly notes it works for standard denominations, not explaining why (canonical property) is a yellow flag.Follow-ups:
  1. How would you modify the dispensing algorithm to balance cash bin depletion — i.e., prevent the ATM from running out of popular denominations too quickly?
  2. If you had to support a _find_dispense_combination method that returns all valid combinations and lets the user choose (e.g., “more small bills please”), how would you implement that?
Strong answer:
  • SHA-256 is a fast cryptographic hash, which is precisely the problem. PIN hashing needs to be slow to resist brute-force attacks. A 4-digit PIN has only 10,000 possible values, and SHA-256 can hash billions per second on modern GPUs. An attacker with access to the hash can brute-force all 10,000 PINs in microseconds.
  • The correct approach is bcrypt, scrypt, or Argon2 — adaptive hashing algorithms with a configurable work factor that makes each hash computation deliberately expensive. Even for a 4-digit PIN, bcrypt with a cost factor of 12 would take roughly 250ms per attempt, making 10,000 attempts take about 40 minutes rather than microseconds.
  • The code also lacks salting. Without a salt, two cards with the same PIN produce the same hash, enabling rainbow table attacks and revealing duplicate PINs. Bcrypt handles this automatically (it generates a random salt per hash).
  • In real ATM systems, PIN verification is rarely done locally. The encrypted PIN block (using Triple DES or AES under a hardware security module) is sent to the issuing bank for verification. The ATM itself never stores or even sees the plaintext PIN — the keypad hardware encrypts it before it reaches the ATM software. This is mandated by PCI PIN Security Requirements.
Red flag answer: “SHA-256 is a secure hashing algorithm so it is fine for PINs.” This misses the crucial distinction between hash strength (collision resistance) and hash speed (brute-force resistance), which is fundamental to password/PIN security.Follow-ups:
  1. If the ATM operates offline (no network to the bank), how would you verify the PIN locally while still maintaining security?
  2. The code resets failed_attempts to 0 on a successful PIN entry. What attack does this enable, and how would you fix it?
Strong answer:
  • Cardless withdrawal replaces the card-based authentication flow with a token-based flow. The state machine needs a new entry point: instead of Idle -> CardInserted -> Authenticated, you add Idle -> OTPVerified -> Authenticated. The Authenticated state and everything after it remains unchanged — this is the power of the State pattern.
  • You would introduce an AuthenticationStrategy interface (Strategy pattern) with implementations like CardPinAuthentication and OTPAuthentication. The ATM delegates authentication to the current strategy rather than hardcoding card + PIN logic. This way, adding biometric auth or NFC-based auth later is just a new strategy class.
  • The OTP flow introduces a new time-sensitive concern: the OTP has an expiration window (usually 3-5 minutes), and the withdrawal amount is pre-authorized in the mobile app. The ATM does not prompt for an amount — it dispenses the pre-authorized amount. This means the TransactionSelectedState logic needs a branch for pre-authorized vs. interactive transactions.
  • In production, the OTP is typically a one-time reference number generated by the bank, not a traditional TOTP. The user enters the reference number and a short PIN (not the card PIN) at the ATM. The ATM sends both to the bank for verification. The transaction amount is locked server-side to prevent tampering.
Red flag answer: “Just add an OTP field to the Card class.” This fundamentally misunderstands the design — cardless means no Card object is involved. It also shows an inability to think about extending a design through composition rather than modifying existing classes.Follow-ups:
  1. How would the state transition diagram change to accommodate both card-based and cardless flows without duplicating states?
  2. What new failure modes does cardless withdrawal introduce that do not exist in card-based withdrawal (think: network dependency, replay attacks)?
Strong answer:
  • The current design tightly couples the ATM to a single Bank instance. In the real world, ATMs connect to an interbank network (like Visa/Mastercard networks, or national networks like STAR, Pulse, or LINK in the UK) that routes requests to the correct issuing bank based on the card’s BIN (Bank Identification Number — the first 6 digits).
  • You would introduce a BankingNetworkGateway interface with methods like verify_card(), verify_pin(), authorize_withdrawal(), and commit_transaction(). The ATM talks to this gateway, not directly to any bank. The gateway routes to the correct bank based on the card’s BIN prefix. This is essentially the Adapter/Facade pattern over multiple external services.
  • The communication protocol changes significantly. Instead of direct method calls on an in-memory Bank object, you are now making network calls using ISO 8583 (the standard message format for financial transactions). Each message includes fields for the card number, transaction amount, terminal ID, and a message authentication code (MAC). The gateway translates between the ATM’s internal API and the ISO 8583 wire format.
  • This also introduces latency, timeouts, and partial failures. The ATM needs a timeout on every network call and a fallback behavior: if the authorization request times out, do you retry? Do you allow “stand-in” processing where the ATM approves a small withdrawal offline and reconciles later? These are real design decisions that ATM networks handle.
Red flag answer: “Just add a list of Bank objects and loop through them to find the right one.” This misses the network routing layer, the protocol translation, and the entire concept of interbank settlement.Follow-ups:
  1. How does the ATM handle a situation where the interbank network is down but a customer urgently needs cash?
  2. What is the reconciliation process when the ATM processes a transaction offline and the bank later disputes the amount?
Strong answer:
  • In-memory state is volatile — if the ATM process crashes, all knowledge of current bin counts, active sessions, and pending transactions is lost. This is acceptable for a design interview prototype but catastrophic in production.
  • Real ATMs persist their state to durable local storage after every state change. The bin counts, transaction journal, and current machine state are written to a local database (often SQLite or a custom journaling file system on the ATM’s internal storage). On restart, the ATM reads this persisted state to recover exactly where it left off.
  • For mid-transaction crashes, the Electronic Journal (EJ) is the recovery mechanism. Each step of a transaction is journaled before execution: “About to dispense $200 from bin-100 x2” is logged before the physical dispense command. On restart, the ATM’s recovery process reads the journal, determines what was in flight, and takes corrective action — typically flagging the transaction as “suspect” for manual reconciliation.
  • The write-ahead log (WAL) pattern from databases applies here: log the intent before the action, so you always know what was supposed to happen. If the journal says “dispense commanded” but there is no “dispense confirmed” entry, the ATM knows cash may or may not have been dispensed and alerts maintenance.
Red flag answer: “You just need to add a database.” This is directionally correct but misses the nuance of write-ahead logging, crash recovery sequencing, and the specific challenge that physical cash dispensing cannot be queried for its current state after a crash.Follow-ups:
  1. How would you implement a write-ahead log for the CashDispenser class specifically, and what entries would you log before vs. after each physical operation?
  2. If the ATM crashes after dispensing cash but before recording the transaction, how does the system detect and handle this discrepancy?