Skip to main content
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

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

State Transition Diagram


📐 Step 3: Class Diagram

Step 4: Implementation

Enums and Constants

Account and Card Classes

Transaction Classes

Hardware Components

ATM State Pattern

ATM Main Class

Step 5: Usage Example

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?