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.

  1. 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.

  2. 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.

  3. 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.

  4. Is one session allowed several transactions?
    Typical answer

    Yes: after authenticating, the customer can do several operations before ejecting the card.

  5. 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.

  6. Do we need daily withdrawal limits?
    Typical answer

    Nice to mention; the bank enforces them, so they fit behind the same debit call.

List the moments in a session: no card, card in but not verified, verified. What may the customer do in each?
If every public method begins with a check on the current mode, what pattern removes those conditionals?
Which object should own money? Which should own physical notes?
Debiting the account and pushing out notes are two separate effects. In which order, and what if the second fails?
Try handing out 60 from a machine loaded with 50s and 20s using largest-note-first. What goes wrong?

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.)

Practice next

Next Post Previous Post
No Comment
Add Comment
comment url