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.
🎯 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
📋 Step 1: Clarify Requirements
Questions to Ask the Interviewer
| Category | Question | Impact on Design |
|---|---|---|
| Hardware | Model card reader, keypad, cash dispenser? | Component classes needed |
| Transactions | Which types? Withdraw, deposit, transfer? | Transaction class hierarchy |
| Security | PIN attempt limits? Card retention? | Security logic |
| Cash | Track denominations? Optimal dispensing? | Chain of Responsibility |
| Limits | Daily withdrawal limits? | Validation rules |
| Network | Online/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
Hardware
Banking
Operations
Entity-Responsibility Mapping
| Entity | Responsibilities | Pattern |
|---|---|---|
ATM | Coordinate components, manage state | Context (State Pattern) |
ATMState | Define behavior for each state | State Pattern |
CashDispenser | Dispense bills in optimal denominations | Chain of Responsibility |
Transaction | Encapsulate transaction logic | Command Pattern |
Bank | Verify cards, process transactions | Singleton |
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
Why State Pattern for ATM?
Why State Pattern for ATM?
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.Why Separate Hardware Components?
Why Separate Hardware Components?
Why Greedy Algorithm for Cash Dispensing?
Why Greedy Algorithm for Cash Dispensing?
Why Thread Locking on Withdrawal?
Why Thread Locking on Withdrawal?
Extension Points
Interview Deep-Dive Questions
Q1: Why is the State pattern the right choice for an ATM, and what would the code look like without it?
Q1: Why is the State pattern the right choice for an ATM, and what would the code look like without it?
- 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
CardInsertedStatetells 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.”
- If an interviewer asked you to add a
MaintenanceModestate where a technician can reload cash bins, how would you integrate it without breaking existing states? - 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?
Q2: Walk me through how you'd ensure transaction atomicity during a withdrawal -- what happens if the machine debits the account but fails to physically dispense cash?
Q2: Walk me through how you'd ensure transaction atomicity during a withdrawal -- what happens if the machine debits the account but fails to physically dispense cash?
- 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 beforecash_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_withdrawalmethod also wraps everything inself._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
TransactionJournalthat logs each step independently: hold placed, dispensing attempted, dispensing succeeded/failed, debit committed/released.
- How would you handle the scenario where the ATM dispenses cash successfully but the network call to finalize the debit at the bank fails?
- How do real banking systems reconcile end-of-day discrepancies between what the ATM’s journal says and what the bank’s ledger shows?
Q3: Two users walk up to the same ATM simultaneously (think: a hardware glitch or queued commands). How does the current design handle concurrent access, and where are the gaps?
Q3: Two users walk up to the same ATM simultaneously (think: a hardware glitch or queued commands). How does the current design handle concurrent access, and where are the gaps?
- 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, theaccount.balancefield could experience a data race. - The state machine itself is not thread-safe:
self.stateis mutated without locking. If a hardware interrupt triggerscancel()whileexecute_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 withself._lock. The state itself becomes the single-threaded serialization point.
- 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?
- 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?
Q4: The cash dispensing algorithm uses a greedy approach for denomination selection. When does greedy fail, and what would you replace it with?
Q4: The cash dispensing algorithm uses a greedy approach for denomination selection. When does greedy fail, and what would you replace it with?
- 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 amounti, 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 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.
- 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?
- If you had to support a
_find_dispense_combinationmethod that returns all valid combinations and lets the user choose (e.g., “more small bills please”), how would you implement that?
Q5: The Card class hashes the PIN with SHA-256. Critique this security approach.
Q5: The Card class hashes the PIN with SHA-256. Critique this security approach.
- 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.
- If the ATM operates offline (no network to the bank), how would you verify the PIN locally while still maintaining security?
- The code resets
failed_attemptsto 0 on a successful PIN entry. What attack does this enable, and how would you fix it?
Q6: How would you extend this ATM design to support cardless withdrawal (e.g., using a mobile app and OTP)?
Q6: How would you extend this ATM design to support cardless withdrawal (e.g., using a mobile app and OTP)?
- 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 addIdle -> OTPVerified -> Authenticated. The Authenticated state and everything after it remains unchanged — this is the power of the State pattern. - You would introduce an
AuthenticationStrategyinterface (Strategy pattern) with implementations likeCardPinAuthenticationandOTPAuthentication. 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
TransactionSelectedStatelogic 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.
- How would the state transition diagram change to accommodate both card-based and cardless flows without duplicating states?
- What new failure modes does cardless withdrawal introduce that do not exist in card-based withdrawal (think: network dependency, replay attacks)?
Q7: The Bank class uses a simple dictionary for accounts and cards. How would this design change if the ATM needs to work with multiple banks through an interbank network?
Q7: The Bank class uses a simple dictionary for accounts and cards. How would this design change if the ATM needs to work with multiple banks through an interbank network?
- The current design tightly couples the ATM to a single
Bankinstance. 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
BankingNetworkGatewayinterface with methods likeverify_card(),verify_pin(),authorize_withdrawal(), andcommit_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
Bankobject, 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.
- How does the ATM handle a situation where the interbank network is down but a customer urgently needs cash?
- What is the reconciliation process when the ATM processes a transaction offline and the bank later disputes the amount?
Q8: The current CashDispenser uses in-memory state for bin counts. What happens when the ATM restarts or crashes mid-transaction?
Q8: The current CashDispenser uses in-memory state for bin counts. What happens when the ATM restarts or crashes mid-transaction?
- 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.
- How would you implement a write-ahead log for the
CashDispenserclass specifically, and what entries would you log before vs. after each physical operation? - If the ATM crashes after dispensing cash but before recording the transaction, how does the system detect and handle this discrepancy?