Design an ATM System
State machine design for an ATM covering card authentication, transaction types (withdraw, deposit, balance check), cash dispensing, and account interaction.
The Problem
Your bank operates 200 ATMs across the city. The current software is a single 3,000-line class with nested if-else blocks for every screen and transaction type. Adding a new transaction (like bill payment) requires editing 40+ branches, and last month a botched deployment dispensed cash without debiting accounts. Three developers refuse to touch the codebase.
An ATM is a classic state machine problem. The machine transitions through well-defined states (idle, card inserted, PIN verified, transaction selected), and each state only allows certain operations. The interviewer wants to see clean state management, pluggable transaction types, and a robust cash dispensing algorithm.
Design the core classes for an ATM system that handles card authentication with retry limits, multiple transaction types (withdraw, deposit, balance inquiry), and cash dispensing with multiple denominations.
Requirements
Clarifying Questions
Before jumping into class design, ask questions to turn the vague prompt into a concrete specification. Cover four areas: core actions, error handling, boundaries, and future extensions.
You: "What transaction types does the ATM support?"
Interviewer: "Withdraw, deposit, and balance inquiry. But the design should make it easy to add new types."
That tells you transaction handling needs to be pluggable. Keep this in mind for patterns.
You: "Is there a PIN retry limit? What happens when a user exceeds it?"
Interviewer: "Three attempts. After that, the card is retained and the account is locked."
Three retries with card retention. That is a critical edge case for the state machine.
You: "What denominations does the ATM dispense? Can the user choose?"
Interviewer: "The ATM holds 100s, 50s, 20s, and 10s. It picks the optimal combination automatically, largest bills first."
Greedy denomination selection from largest to smallest. This is a Chain of Responsibility signal.
You: "Is there a daily withdrawal limit?"
Interviewer: "Yes, $1,000 per card per day. Track it within the ATM session for now."
Good. You need to track cumulative withdrawals per card.
You: "Should the ATM handle multiple cards at once, or one session at a time?"
Interviewer: "One session at a time. The ATM serves a single user from card insertion to card ejection."
Single-session simplifies concurrency. The ATM is a state machine with one active session.
You: "What happens if the ATM runs out of cash mid-transaction?"
Interviewer: "Check cash availability before debiting the account. Never debit if you cannot dispense."
That last question matters because the order of operations (check cash, then debit, then dispense) prevents the worst failure mode: debiting without dispensing.
You: "Should we handle receipt printing?"
Interviewer: "Mention it as an extension. Not in the core design."
You: "Is the bank account service in scope, or do we treat it as an external dependency?"
Interviewer: "Treat it as an interface. The ATM calls it, but you don't design the bank's internals."
Perfect. You have clarified scope and ruled out unnecessary complexity.
Final Requirements
Functional Requirements:
- The ATM accepts a card, validates the PIN (up to 3 attempts), and starts a session
- Users select a transaction type: withdraw, deposit, or balance inquiry
- Withdrawals dispense cash in optimal denominations (largest bills first)
- The ATM enforces a $1,000 daily withdrawal limit per card
- After a transaction completes (or is cancelled), the ATM ejects the card and returns to idle
- Failed PIN authentication after 3 attempts retains the card and locks the account
Non-Functional Requirements:
- Adding a new transaction type requires a new class, not changes to existing code
- The ATM state machine enforces valid transitions (no dispensing cash from the idle state)
- Cash availability is verified before any account debit occurs
Out of Scope:
- Bank account service internals (use an interface)
- Receipt printing
- Multi-session / concurrent users
- Network failure handling and retry logic
- Physical hardware integration
- UI rendering
Example Inputs and Outputs
Scenario 1: Successful withdrawal
- Input: Card "4111-1111-1111-1111" inserted, PIN "1234" entered, withdraw $280
- Expected: PIN accepted on first attempt, ATM dispenses 2x$100 + 1x$50 + 1x$20 + 1x$10, account debited $280, card ejected
- Why: Validates the full happy path including denomination breakdown
Scenario 2: PIN failure and card retention
- Input: Card "4222-2222-2222-2222" inserted, wrong PIN entered 3 times
- Expected: First two attempts show "incorrect PIN, X attempts remaining." Third failure retains card, locks account, ATM returns to idle
- Why: Validates retry logic and the terminal failure state
Scenario 3: Insufficient ATM cash
- Input: Card inserted, PIN verified, withdraw $500 but ATM only has $300 total
- Expected: Transaction rejected with "Insufficient cash in ATM." Account is NOT debited. User can try a smaller amount or cancel.
- Why: Validates that cash check precedes account debit
Scenario 4: Daily limit exceeded
- Input: User already withdrew $800 today, now requests $300 more
- Expected: Transaction rejected with "Daily limit exceeded. Remaining: $200." Account not debited.
- Why: Validates cumulative daily tracking per card
Try It Yourself
Try it yourself
Before reading the solution, spend 15-20 minutes sketching the ATM state machine and identifying which states allow which operations. Think about what pattern handles the "one session, many screens" problem. Compare your approach with the walkthrough below.
Step 1: Identify Core Entities
Start by asking: what are the main "things" in this system? Look at your requirements and pull out the nouns. Each noun that has its own lifecycle or responsibilities becomes a candidate class.
A common mistake is putting the entire ATM logic (state machine, dispensing, authentication, transaction handling) into one god class. Good design starts by giving each concept its own home.
| Entity | Responsibility | Key attributes |
|---|---|---|
| ATM | The orchestrator. Holds the state machine, cash dispenser, and coordinates transactions. | currentState, cashDispenser, bankService |
| ATMState | Interface for state behavior. Each state knows what actions are valid. | insertCard(), authenticatePin(), selectTransaction() |
| Card | Data holder for the inserted card. Links to an account. | cardNumber, pinHash, accountId |
| Account | Bank-side data. Balance and status. Accessed through BankService. | accountId, balance, status |
| Transaction | Strategy interface. Each type (withdraw, deposit, balance) executes differently. | execute(account, amount) |
| CashDispenser | Manages the physical cash inventory. Knows available denominations and counts. | denominationSlots |
| DenominationHandler | Chain of Responsibility link. Handles one denomination, passes remainder to the next. | denomination, count, nextHandler |
| TransactionRecord | Value object capturing what happened. Immutable audit trail entry. | transactionId, type, amount, timestamp, status |
Notice we separated CashDispenser from ATM because dispensing logic (denomination math, inventory tracking) is a distinct responsibility. The ATM orchestrates the session; the dispenser handles physical cash. Merging them would violate SRP.
We also keep Transaction as a strategy interface rather than a concrete class because each transaction type (withdraw, deposit, balance inquiry) has fundamentally different execution logic. A withdrawal debits and dispenses cash. A deposit credits and accepts cash. A balance inquiry just reads data.
Step 2: Define Relationships and Class Design
ATM (the orchestrator)
The ATM class manages the session lifecycle. It holds the current state, delegates every user action to that state, and transitions between states based on results.
Deriving state from requirements:
| Requirement | What ATM must track |
|---|---|
| "Accepts a card and validates PIN" | The inserted card, remaining PIN attempts |
| "State machine with valid transitions" | The current ATMState object |
| "Dispenses cash in denominations" | A CashDispenser for cash operations |
| "$1,000 daily withdrawal limit" | Cumulative withdrawal amount for the session |
| "Ejects card and returns to idle" | State transition method |
This gives us the core state: currentState, insertedCard, pinAttemptsLeft, cashDispenser, bankService, and dailyWithdrawnAmount.
Deriving methods from needs:
| Need from requirements | Method |
|---|---|
| "User inserts card" | insertCard(card) |
| "User enters PIN" | enterPin(pin) |
| "User selects transaction" | selectTransaction(type, amount) |
| "User cancels at any point" | cancel() |
Each method delegates to currentState. The state object decides whether the operation is allowed.
CashDispenser (chain coordinator)
The CashDispenser owns a chain of DenominationHandler objects. Each handler tries to fill as much of the remaining amount as possible with its denomination, then passes the remainder to the next handler in the chain.
Deriving state from requirements:
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with NotesFromSDE Premium.