ATM
State pattern for ATM lifecycle management, Chain of Responsibility for greedy bill dispensing across denominations, and clean separation between authentication, transaction, and cash handling concerns.
Key Abstractions
Context class that delegates all operations to its current state
Interface defining valid operations: insertCard, enterPin, withdraw, ejectCard
Waiting for card insertion. Rejects pin entry and withdrawal.
Card present, awaiting PIN. Rejects withdrawal until authenticated.
PIN verified. Allows withdrawal and card ejection.
Chain of Responsibility handler for denomination-based bill dispensing
Holds balance, validates PIN, processes debits
Immutable record of a completed withdrawal
Class Diagram
What This Is Really About
An ATM looks simple from the outside: stick in a card, punch in a PIN, grab your cash. But from a design perspective, it's two patterns working together. The State pattern manages what operations are valid at any given moment. Chain of Responsibility handles the physical act of counting out bills.
Without the State pattern, you'd have every method in the ATM littered with checks like "is a card inserted?" and "has the user authenticated?" Miss one check, and you've got a security hole where someone can withdraw cash without entering a PIN. State makes those illegal transitions structurally impossible.
Requirements
Functional
- Accept a bank card and authenticate via PIN with limited retry attempts
- Process cash withdrawals by debiting the linked account
- Dispense bills using the largest denominations first to minimize bill count
- Eject the card and return to an idle state
- Reject invalid operations based on current ATM state
Non-Functional
- Thread-safe account debits and cash dispenser operations
- PIN stored as a hash, never in plaintext
- ATM must recover gracefully from failed transactions (refund dispensed bills on debit failure)
Design Decisions
Why State pattern over if/else on an enum?
Consider what happens with an enum approach. Every method in the ATM class needs a switch statement checking the current state. withdraw() checks state, enterPin() checks state, ejectCard() checks state. Add a new state (say, "MaintenanceMode") and you need to update every single switch. With the State pattern, each state class only handles its own valid operations. Invalid operations throw immediately. New states are self-contained.
Why Chain of Responsibility for dispensing?
Bill dispensing is a greedy algorithm: use as many $100 bills as possible, then $50, then $20, then $10. Each denomination handler takes what it can and passes the remainder to the next handler. Adding a new denomination ($5 bills, for instance) means inserting one new handler into the chain. No existing handler changes. Compare that to a single method with nested if/else for each denomination.
Why dispense before debiting?
This feels backwards, but there's a good reason. If you debit first and then discover the ATM can't make change for $35 (it only has $50 and $20 bills), you've already taken the customer's money. By checking dispensability first, you fail fast without touching the account. If the debit then fails (insufficient funds), you refund the physical bills back to the dispenser.
Why hash the PIN?
Even in a simulation, storing PINs as plaintext is a bad habit. In production, the PIN would be validated by the bank's backend, not locally. But modeling it as a hash demonstrates the security mindset interviewers expect.
Interview Follow-ups
- "How would you handle deposits?" Add a DepositState that accepts cash/checks, validates the deposit, and credits the account. The state machine gets a new node, but existing states don't change.
- "What about daily withdrawal limits?" Add a WithdrawalPolicy that tracks daily totals per card. The AuthenticatedState checks the policy before dispensing.
- "How would you handle multiple currencies?" Each CashDispenser chain serves one currency. The ATM selects the right chain based on the card's currency or user preference.
- "What if the ATM runs low on a denomination mid-transaction?" The chain naturally handles this. If the $100 handler only has 1 bill left, it dispenses one and passes the rest to the $50 handler. If no combination works, the dispense fails before any debit.
Code Implementation
1 from abc import ABC, abstractmethod
2 from dataclasses import dataclass, field
3 from decimal import Decimal
4 from datetime import datetime
5 from enum import Enum
6 import uuid
7 import hashlib
8 import threading
9
10
11 class ATMError(Exception):
12 pass
13
14
15 class InvalidStateError(ATMError):
16 pass
17
18
19 class InsufficientFundsError(ATMError):
20 pass
21
22
23 class AuthenticationError(ATMError):
24 pass
25
26
27 class DispenseError(ATMError):
28 pass
29
30
31 @dataclass
32 class Account:
33 account_id: str
34 _balance: Decimal
35 _pin_hash: str
36 _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
37
38 @staticmethod
39 def hash_pin(pin: str) -> str:
40 return hashlib.sha256(pin.encode()).hexdigest()
41
42 def validate_pin(self, pin: str) -> bool:
43 return self.hash_pin(pin) == self._pin_hash
44
45 def debit(self, amount: Decimal) -> None:
46 with self._lock:
47 if amount > self._balance:
48 raise InsufficientFundsError(
49 f"Balance {self._balance}, requested {amount}"
50 )
51 self._balance -= amount
52
53 @property
54 def balance(self) -> Decimal:
55 return self._balance
56
57
58 @dataclass(frozen=True)
59 class Card:
60 card_number: str
61 account: Account
62
63
64 @dataclass(frozen=True)
65 class Transaction:
66 id: str
67 card_number: str
68 amount: Decimal
69 denominations: dict[int, int]
70 timestamp: datetime
71
72
73 class CashDispenser:
74 """Chain of Responsibility for bill dispensing. Greedy largest-first."""
75
76 def __init__(self, denomination: int, count: int):
77 self._denomination = denomination
78 self._count = count
79 self._next: CashDispenser | None = None
80 self._lock = threading.Lock()
81
82 def set_next(self, handler: "CashDispenser") -> "CashDispenser":
83 self._next = handler
84 return handler
85
86 def dispense(self, amount: int) -> dict[int, int]:
87 result: dict[int, int] = {}
88 self._dispense_recursive(amount, result)
89 dispensed_total = sum(d * c for d, c in result.items())
90 if dispensed_total != amount:
91 raise DispenseError(
92 f"Cannot dispense exactly ${amount} with available denominations"
93 )
94 return result
95
96 def _dispense_recursive(self, amount: int, result: dict[int, int]) -> int:
97 with self._lock:
98 bills_needed = amount // self._denomination
99 bills_used = min(bills_needed, self._count)
100 if bills_used > 0:
101 result[self._denomination] = bills_used
102 self._count -= bills_used
103 amount -= bills_used * self._denomination
104
105 if amount > 0 and self._next:
106 amount = self._next._dispense_recursive(amount, result)
107 return amount
108
109 def refund(self, denominations: dict[int, int]) -> None:
110 if self._denomination in denominations:
111 with self._lock:
112 self._count += denominations[self._denomination]
113 if self._next:
114 self._next.refund(denominations)
115
116
117 class ATMState(ABC):
118 @abstractmethod
119 def insert_card(self, atm: "ATM", card: Card) -> None: ...
120
121 @abstractmethod
122 def enter_pin(self, atm: "ATM", pin: str) -> None: ...
123
124 @abstractmethod
125 def withdraw(self, atm: "ATM", amount: int) -> Transaction: ...
126
127 @abstractmethod
128 def eject_card(self, atm: "ATM") -> None: ...
129
130
131 class IdleState(ATMState):
132 def insert_card(self, atm: "ATM", card: Card) -> None:
133 atm._current_card = card
134 atm._set_state(CardInsertedState())
135 print(f" Card {card.card_number} inserted.")
136
137 def enter_pin(self, atm: "ATM", pin: str) -> None:
138 raise InvalidStateError("Insert a card first.")
139
140 def withdraw(self, atm: "ATM", amount: int) -> Transaction:
141 raise InvalidStateError("Insert a card first.")
142
143 def eject_card(self, atm: "ATM") -> None:
144 raise InvalidStateError("No card to eject.")
145
146
147 class CardInsertedState(ATMState):
148 def __init__(self):
149 self._attempts = 0
150 self._max_attempts = 3
151
152 def insert_card(self, atm: "ATM", card: Card) -> None:
153 raise InvalidStateError("A card is already inserted.")
154
155 def enter_pin(self, atm: "ATM", pin: str) -> None:
156 card = atm._current_card
157 if card.account.validate_pin(pin):
158 atm._set_state(AuthenticatedState())
159 print(" PIN accepted. You are now authenticated.")
160 else:
161 self._attempts += 1
162 remaining = self._max_attempts - self._attempts
163 if remaining <= 0:
164 print(" Too many failed attempts. Ejecting card.")
165 atm._current_card = None
166 atm._set_state(IdleState())
167 else:
168 raise AuthenticationError(
169 f"Wrong PIN. {remaining} attempts remaining."
170 )
171
172 def withdraw(self, atm: "ATM", amount: int) -> Transaction:
173 raise InvalidStateError("Enter your PIN first.")
174
175 def eject_card(self, atm: "ATM") -> None:
176 print(f" Card {atm._current_card.card_number} ejected.")
177 atm._current_card = None
178 atm._set_state(IdleState())
179
180
181 class AuthenticatedState(ATMState):
182 def insert_card(self, atm: "ATM", card: Card) -> None:
183 raise InvalidStateError("A card is already inserted.")
184
185 def enter_pin(self, atm: "ATM", pin: str) -> None:
186 raise InvalidStateError("Already authenticated.")
187
188 def withdraw(self, atm: "ATM", amount: int) -> Transaction:
189 account = atm._current_card.account
190
191 # Try to dispense bills first, so we fail before debiting
192 try:
193 denominations = atm._dispenser.dispense(amount)
194 except DispenseError as e:
195 raise DispenseError(str(e))
196
197 # Debit the account. If it fails, refund the physical bills.
198 try:
199 account.debit(Decimal(amount))
200 except InsufficientFundsError:
201 atm._dispenser.refund(denominations)
202 raise
203
204 txn = Transaction(
205 id=str(uuid.uuid4())[:8],
206 card_number=atm._current_card.card_number,
207 amount=Decimal(amount),
208 denominations=denominations,
209 timestamp=datetime.now(),
210 )
211 atm._transactions.append(txn)
212 print(f" Dispensed ${amount}: {denominations}")
213 return txn
214
215 def eject_card(self, atm: "ATM") -> None:
216 print(f" Card {atm._current_card.card_number} ejected.")
217 atm._current_card = None
218 atm._set_state(IdleState())
219
220
221 class ATM:
222 def __init__(self, dispenser: CashDispenser):
223 self._state: ATMState = IdleState()
224 self._dispenser = dispenser
225 self._current_card: Card | None = None
226 self._transactions: list[Transaction] = []
227
228 def _set_state(self, state: ATMState) -> None:
229 self._state = state
230
231 def insert_card(self, card: Card) -> None:
232 self._state.insert_card(self, card)
233
234 def enter_pin(self, pin: str) -> None:
235 self._state.enter_pin(self, pin)
236
237 def withdraw(self, amount: int) -> Transaction:
238 return self._state.withdraw(self, amount)
239
240 def eject_card(self) -> None:
241 self._state.eject_card(self)
242
243
244 def build_dispenser() -> CashDispenser:
245 """Build a chain: $100 -> $50 -> $20 -> $10"""
246 hundreds = CashDispenser(100, 10)
247 fifties = CashDispenser(50, 20)
248 twenties = CashDispenser(20, 30)
249 tens = CashDispenser(10, 50)
250 hundreds.set_next(fifties)
251 fifties.set_next(twenties)
252 twenties.set_next(tens)
253 return hundreds
254
255
256 if __name__ == "__main__":
257 dispenser = build_dispenser()
258 atm = ATM(dispenser)
259
260 # Set up an account with a known PIN
261 account = Account("ACC-001", Decimal("1500.00"), Account.hash_pin("1234"))
262 card = Card("4111-1111-1111-1111", account)
263
264 print("=== ATM Session ===\n")
265
266 # Insert card
267 print("1. Inserting card...")
268 atm.insert_card(card)
269
270 # Wrong PIN attempt
271 print("\n2. Entering wrong PIN...")
272 try:
273 atm.enter_pin("0000")
274 except AuthenticationError as e:
275 print(f" Error: {e}")
276
277 # Correct PIN
278 print("\n3. Entering correct PIN...")
279 atm.enter_pin("1234")
280
281 # Withdraw $280 (should get 2x$100 + 1x$50 + 1x$20 + 1x$10)
282 print("\n4. Withdrawing $280...")
283 txn = atm.withdraw(280)
284 print(f" Transaction ID: {txn.id}")
285 print(f" Account balance: ${account.balance}")
286
287 # Withdraw $150
288 print("\n5. Withdrawing $150...")
289 txn2 = atm.withdraw(150)
290 print(f" Account balance: ${account.balance}")
291
292 # Eject card
293 print("\n6. Ejecting card...")
294 atm.eject_card()
295
296 # Try to withdraw after ejection
297 print("\n7. Attempting withdrawal without card...")
298 try:
299 atm.withdraw(100)
300 except InvalidStateError as e:
301 print(f" Error: {e}")
302
303 print(f"\nTotal transactions: {len(atm._transactions)}")Common Mistakes
- ✗Using if/else chains on an enum instead of the State pattern. You end up with every method checking state, and forgetting one check means dispensing cash without a PIN.
- ✗Not validating withdrawal amount against available denominations. $35 can't be dispensed with $20 and $10 bills alone.
- ✗Forgetting to reset ATM state after card ejection or transaction failure. The machine gets stuck in an invalid state.
- ✗Dispensing without checking if the ATM's physical cash supply covers the withdrawal. Balance check alone isn't enough.
Key Points
- ✓State pattern makes invalid operations per state a compile-time concern, not a runtime if/else maze
- ✓Chain of Responsibility for dispensing: $100 -> $50 -> $20 -> $10. Greedy largest-first minimizes bill count.
- ✓Each state only implements the operations valid for that state. Everything else throws an explicit error.
- ✓Adding a new denomination means adding one handler to the chain. No existing code changes.