Design an ATM
Design the software inside a cash machine. A customer inserts a bank card, types a PIN, and can then check a balance, deposit money or take out cash before the card is returned. The machine talks to the bank over a network for anything involving money or PINs, and it holds a limited stock of notes in a few denominations.
Interviewers use this problem to see whether you can model behaviour that depends on where a session is: the same button means different things before and after the PIN, and some actions must be refused outright. They also want to see that cash and account balance never drift apart, even when the dispenser jams.
In 45 minutes, define the classes, draw the session state machine, and implement withdrawal end to end, including choosing which notes to hand out.
Use the LLD board for your class diagram and the LLD code tab to write and run your classes.
Design Path · Low-Level Design · problem 68 of 70
Ask before you design. Try answering each yourself, then compare.
- Where are balances and PINs checked?
Typical answer
At the bank. The ATM never stores them; it calls a bank interface that can fail or be slow.
- What happens after several wrong PINs?
Typical answer
After three wrong attempts in one session the card is retained and the machine returns to idle.
- Which denominations does the machine hold?
Typical answer
A few, for example 100, 50 and 20, each with a limited count. Requests that cannot be made from the loaded notes are refused.
- Is one session allowed several transactions?
Typical answer
Yes: after authenticating, the customer can do several operations before ejecting the card.
- How are deposits handled?
Typical answer
Treat the deposit as an amount that is credited immediately; physical note counting and envelope checks are out of scope.
- Do we need daily withdrawal limits?
Typical answer
Nice to mention; the bank enforces them, so they fit behind the same debit call.
Requirements
Functional
- Insert a card and verify the PIN with the bank, retaining the card after three failures.
- Show the balance, accept deposits and dispense withdrawals for an authenticated session.
- Choose a combination of loaded notes for each withdrawal, or refuse if none exists.
- Return the card on request and go back to idle.
Non-functional
- An account is never debited without cash leaving the machine, and cash never leaves without a debit.
- Each operation completes within 2 seconds, dominated by the bank round trip.
- Operations not allowed in the current state are rejected with a clear message, never half-executed.
Out of scope
- Card reader, keypad and screen drivers.
- Bank-side ledger, fraud checks and network protocols.
- Cheque deposits and transfers between accounts.
Class design
Session states. The ATM is a thin context: each public call is forwarded to its current ATMState. The base state refuses everything with a message naming the state. IdleState accepts only a card. PinEntryState verifies PINs with the bank, counts failures and retains the card on the third. AuthenticatedState enables balance, deposit and withdrawal. Both card-holding states share CardPresentState, which implements eject once.
Money and notes. The Bank interface owns balances and PINs, so the machine holds no account data and can be tested against an in-memory fake. CashDispenser owns the note inventory and plans a payout with a small bounded search over denominations, because largest-first fails for amounts like 60 from 50s and 20s.
Withdraw flow. Plan the notes first (no side effects), then debit the account, then dispense. If the dispenser fails after the debit, the machine credits the amount back, so money and cash stay consistent.
Extensibility. A maintenance or out-of-cash state is one new class; a different bank network is one new Bank implementation.
Open the LLD board and press Reference to see the class diagram.
Patterns used
- State: each session phase is a class, so allowed actions and transitions live together instead of in if-chains.
- Dependency inversion: the ATM depends on a Bank interface, which keeps PINs and balances out of the machine and makes testing easy.
- Compensating action: a dispenser failure after a successful debit is undone with a credit, keeping the two systems in step.
Reference implementation
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Dict, Optional
class ATMError(Exception):
pass
@dataclass(frozen=True)
class Card:
number: str
account_id: str
class Bank(ABC):
"""The ATM never stores balances or PINs; it asks the bank."""
@abstractmethod
def verify_pin(self, card: Card, pin: str) -> bool: ...
@abstractmethod
def balance(self, account_id: str) -> int: ...
@abstractmethod
def debit(self, account_id: str, amount: int): ...
@abstractmethod
def credit(self, account_id: str, amount: int): ...
class InMemoryBank(Bank):
def __init__(self):
self.pins: Dict[str, str] = {}
self.balances: Dict[str, int] = {}
def open_account(self, card: Card, pin: str, balance: int):
self.pins[card.number], self.balances[card.account_id] = pin, balance
def verify_pin(self, card, pin):
return self.pins.get(card.number) == pin
def balance(self, account_id):
return self.balances[account_id]
def debit(self, account_id, amount):
if self.balances[account_id] < amount:
raise ATMError("insufficient funds")
self.balances[account_id] -= amount
def credit(self, account_id, amount):
self.balances[account_id] += amount
class CashDispenser:
def __init__(self, inventory: Dict[int, int]):
self.inventory = dict(inventory) # denomination -> note count
def plan(self, amount: int) -> Optional[Dict[int, int]]:
"""Pick notes for `amount` from what is loaded. Plain greedy fails cases like 60 from {50, 20}."""
denoms = sorted(self.inventory, reverse=True)
def solve(i: int, rest: int) -> Optional[Dict[int, int]]:
if rest == 0:
return {}
if i == len(denoms):
return None
d = denoms[i]
for n in range(min(self.inventory[d], rest // d), -1, -1):
sub = solve(i + 1, rest - n * d)
if sub is not None:
return {d: n, **sub} if n else sub
return None
return solve(0, amount)
def dispense(self, notes: Dict[int, int]):
for d, n in notes.items():
self.inventory[d] -= n
def total(self) -> int:
return sum(d * n for d, n in self.inventory.items())
class ATMState(ABC):
"""Each state allows some operations; everything else is refused."""
name = "?"
def _refuse(self, action: str):
raise ATMError(f"cannot {action} while {self.name}")
def insert_card(self, atm: "ATM", card: Card): self._refuse("insert a card")
def enter_pin(self, atm: "ATM", pin: str): self._refuse("enter a PIN")
def balance(self, atm: "ATM") -> int: self._refuse("check balance")
def deposit(self, atm: "ATM", amount: int) -> int: self._refuse("deposit")
def withdraw(self, atm: "ATM", amount: int) -> Dict[int, int]: self._refuse("withdraw")
def eject(self, atm: "ATM"): self._refuse("eject")
class IdleState(ATMState):
name = "idle"
def insert_card(self, atm, card):
atm.card, atm.attempts = card, 0
atm.state = PinEntryState()
class CardPresentState(ATMState):
"""Shared by every state that has a card inside."""
def eject(self, atm):
atm.card, atm.state = None, IdleState()
class PinEntryState(CardPresentState):
name = "awaiting PIN"
def enter_pin(self, atm, pin):
if atm.bank.verify_pin(atm.card, pin):
atm.state = AuthenticatedState()
return
atm.attempts += 1
if atm.attempts >= atm.MAX_PIN_ATTEMPTS:
atm.card, atm.state = None, IdleState() # card retained, not returned
raise ATMError("too many wrong PINs: card retained")
raise ATMError(f"wrong PIN, attempts left: {atm.MAX_PIN_ATTEMPTS - atm.attempts}")
class AuthenticatedState(CardPresentState):
name = "authenticated"
def balance(self, atm):
return atm.bank.balance(atm.card.account_id)
def deposit(self, atm, amount):
if amount <= 0:
raise ATMError("deposit must be positive")
atm.bank.credit(atm.card.account_id, amount)
return atm.bank.balance(atm.card.account_id)
def withdraw(self, atm, amount):
return atm.withdraw_cash(atm.card.account_id, amount)
class ATM:
MAX_PIN_ATTEMPTS = 3
def __init__(self, bank: Bank, dispenser: CashDispenser):
self.bank, self.dispenser = bank, dispenser
self.state: ATMState = IdleState()
self.card: Optional[Card] = None
self.attempts = 0
# public API: every call is delegated to the current state
def insert_card(self, card: Card): return self.state.insert_card(self, card)
def enter_pin(self, pin: str): return self.state.enter_pin(self, pin)
def balance(self) -> int: return self.state.balance(self)
def deposit(self, amount: int) -> int: return self.state.deposit(self, amount)
def withdraw(self, amount: int) -> Dict[int, int]: return self.state.withdraw(self, amount)
def eject(self): return self.state.eject(self)
def withdraw_cash(self, account_id: str, amount: int) -> Dict[int, int]:
"""Plan notes first, then debit, then dispense; reverse the debit if the hardware fails."""
notes = self.dispenser.plan(amount) if amount > 0 else None
if notes is None:
raise ATMError(f"cannot dispense {amount} with the notes loaded")
self.bank.debit(account_id, amount)
try:
self.dispenser.dispense(notes)
except Exception:
self.bank.credit(account_id, amount)
raise ATMError("dispenser fault: your account was not charged")
return notes
if __name__ == "__main__":
bank = InMemoryBank()
alice, bob = Card("4000-0001", "acc-alice"), Card("4000-0002", "acc-bob")
bank.open_account(alice, "1234", 200)
bank.open_account(bob, "9999", 80)
atm = ATM(bank, CashDispenser({100: 2, 50: 1, 20: 10}))
def attempt(label, fn, *args):
try:
result = fn(*args)
print(f"{label:<20} -> {'ok' if result is None else result} [{atm.state.name}]")
except ATMError as e:
print(f"{label:<20} -> refused: {e} [{atm.state.name}]")
attempt("withdraw (no card)", atm.withdraw, 100)
attempt("alice inserts card", atm.insert_card, alice)
attempt("PIN 0000", atm.enter_pin, "0000")
attempt("PIN 1234", atm.enter_pin, "1234")
attempt("balance", atm.balance)
attempt("withdraw 60", atm.withdraw, 60)
attempt("withdraw 15", atm.withdraw, 15)
attempt("deposit 40", atm.deposit, 40)
attempt("withdraw 300", atm.withdraw, 300)
attempt("eject", atm.eject)
attempt("bob inserts card", atm.insert_card, bob)
for pin in ("1111", "2222", "3333"):
attempt(f"PIN {pin}", atm.enter_pin, pin)
print("cash left in machine:", atm.dispenser.total(), atm.dispenser.inventory)
Trade-offs
- State classes remove conditionals but spread behaviour over several small files; for three states an enum and a table could also work.
- Debit-then-dispense needs a compensating credit on hardware failure; dispense-then-debit risks giving away cash when the bank is down.
- A bounded search finds a valid note mix when greedy fails but costs more CPU, which is irrelevant at ATM scale.
- Keeping the ATM stateless about accounts means every action needs a bank round trip, trading latency for correctness.
What a strong answer shows
- Draws the session state machine and maps it onto State classes.
- Keeps balances and PIN checks in the bank, not in the ATM.
- Orders withdraw steps so a failure never leaves cash and balance inconsistent.
- Handles note selection correctly, including cases greedy gets wrong, and limited note counts.
- Rejects actions that are invalid for the current state.
- Handles PIN retry limits and card retention.
Mistakes to avoid
- Scattering if state == ... checks across every method.
- Dispensing cash before the debit succeeds, or debiting without a plan to reverse it.
- Storing the account balance on the ATM or the card object.
- Greedy note selection that wrongly refuses amounts the machine could pay.
- Forgetting to reset the PIN attempt counter when a new card is inserted.
Follow-ups to expect
- What if the bank call times out after debiting? (Idempotent transaction ids, and reverse on timeout once the outcome is known.)
- How would you add an out-of-service state? (A new State entered when cash runs low or a fault occurs; it refuses cards.)
- How would you prefer handing out smaller notes? (Swap the planning strategy, e.g. minimise large notes or balance stock.)
- How would you support a daily limit? (The bank checks it inside debit; the ATM just shows the error.)