Design an ATM
An ATM is a real-world-entity problem of the classic kind: a physical machine with a well-defined lifecycle — a card goes in, a PIN is verified, a transaction completes, the card comes out. The design must enforce that lifecycle precisely — preventing a withdrawal before authentication, blocking a second card insertion while one is already active — and must protect account balances from the race conditions that arise when the same account is used from two channels at once — an ATM and online banking, say.
See It in Action
The widget runs the ATM state machine: insert a card, enter PIN 1234, withdraw or check the balance, and eject the card.
Clarifying Requirements
A few targeted questions turn "design an ATM" into a spec you can design against. The functional requirements we will commit to: a user inserts a card linked to an account; the ATM prompts for a PIN; a correct PIN grants an authenticated session; from that session the user can withdraw funds or query their balance; ejecting the card ends the session and returns the machine to idle. An incorrect PIN is rejected with a message; the card stays inserted so the user can try again. A withdrawal is rejected if the account has insufficient funds.
The lifecycle at a glance — before any code is written, the states and allowed transitions tell you exactly what the design must enforce:
Core Entities
Scanning the requirements for nouns that carry state or enforce rules gives a short list.
An Account holds an account ID, a PIN, and a balance. It knows whether a given PIN is
correct and it enforces the rule that a withdrawal cannot exceed the available balance. The
balance guard lives inside Account.withdraw, not scattered across callers — this is the
"Tell, Don't Ask" principle.
A Card is the physical token that names the account it unlocks. It carries an Account
reference; nothing more.
The ATM is the context object. It holds the current card, delegates operations to the
account, and advances through a state machine. Rather than a single state: str field with a
chain of if/elif branches, we use the State pattern: each legal ATM state is its own
class that implements the same interface. The ATM's behavior changes automatically when its
state object is swapped.
ATMState is the abstract interface that every concrete state implements: insert_card,
enter_pin, withdraw, get_balance, eject_card. Concrete states are IdleState,
CardInsertedState, and AuthenticatedState. Each method either performs the action and
transitions to the next state, or returns an error string for illegal operations.
The four entities and their primary relationships in one view:
Designing the Classes
Step 3 of the delivery framework says: for each entity, derive its state (fields) from the requirements, then its behavior (method signatures) from what callers must be able to ask of it. Do not add fields because they seem natural; add them because a specific requirement demands them. Then apply Tell, Don't Ask — the class decides what to do with its own state rather than exposing raw data for callers to interrogate.
One decision sets the shape of the whole withdrawal path: the rule "a withdrawal cannot exceed
the balance" needs a home. The caller — AuthenticatedState — could read the balance and compare
it against the amount itself, or the Account could own the check. Before reading on, decide
which placement also makes the concurrent two-ATM case safe.
Decision checkpoint
Where should the balance-sufficiency check for a withdrawal live: in AuthenticatedState, which reads account.balance and compares, or inside Account.withdraw?
Account
The requirements say an account holds a balance and a PIN, validates PIN attempts, and enforces
the rule that withdrawals cannot exceed the available balance. From those three sentences, the
fields are clear: account_id (for identification), _pin (private — no caller should read
the PIN directly, only verify against it), and balance. The behavior follows directly.
verify_pin(pin: str) -> bool — the account compares the presented PIN against its stored one
and returns the verdict. The caller never sees the stored PIN; it only learns yes or no.
withdraw(amount: float) -> bool — the account checks its own balance, deducts if sufficient,
and returns whether the withdrawal succeeded. This is Tell, Don't Ask in action: callers do
not read balance and check balance >= amount themselves; they tell the account to withdraw
and trust the answer. The guard lives in exactly one place.
get_balance() -> float — returns the current balance for display purposes.
A fourth field, _lock, is required by the concurrency requirement: concurrent channels touching
the same account — an ATM and an online transfer, say — must not race on balance. The lock is an
implementation detail of Account, invisible
to all callers.
ATMState (interface) and concrete states
The lifecycle requirement — idle → card inserted → authenticated, with eject returning to idle
from any state — has to be enforced somewhere: a withdrawal must be impossible before
authentication, a second card must be refused while one is active. One option is a state: str
field on the ATM and an if self._state == "idle" chain inside every method. Before reading on,
decide whether that holds up as the legal operations multiply across states.
Decision checkpoint
A session runs through a fixed lifecycle — idle, card inserted, authenticated — and which operations are even legal depends on the phase (a withdrawal must be refused until the PIN is verified). Where should the rule 'this operation isn't allowed yet' live: as a precondition inside each operation, or with whatever owns the current phase?
The State pattern maps directly onto the lifecycle. Rather than one ATM class with a growing
chain of if self._state == "idle" branches, each legal machine state becomes its own class
implementing a common interface.
The ATMState interface declares the five operations that any caller might invoke on an ATM:
insert_card, enter_pin, withdraw, get_balance, and eject_card. Every concrete state
must implement all five. Operations that are illegal in a given state return an error string
rather than silently succeeding or raising — this keeps the entry function simple: every
command produces exactly one output line.
IdleState holds no fields — the machine is waiting with nothing inserted. Its only legal
operation is insert_card: it stores the card on the ATM context and transitions to
CardInsertedState. Every other operation returns "No card inserted" or "Not authenticated".
CardInsertedState also holds no fields; the card itself lives on the ATM context. Its legal
operations are enter_pin (which asks the card's account to verify the PIN, then either
transitions to AuthenticatedState or stays put) and eject_card (which clears the card and
returns to IdleState). A second insert_card call while a card is already in returns "Card already inserted" without advancing state.
AuthenticatedState is where real work happens. withdraw delegates to
account.withdraw(amount) — the account enforces the balance check — and formats the result
string. get_balance reads and formats the current balance. eject_card clears the card and
returns to IdleState. A duplicate insert_card or a redundant enter_pin both return
appropriate error strings.
The key design decision: concrete states are stateless singletons. IdleState,
CardInsertedState, and AuthenticatedState carry no mutable data. All mutable state lives in
the ATM context (card) and in Account (balance). This means a single instance of each
state class can be created at ATM construction time and reused for the machine's entire
lifetime — no allocation on every transition.
ATM (context)
The ATM's state from the requirements is the current card (card: Card | None) and the current
state object (_state: ATMState). Its behavior is a thin facade: five public methods that each
forward to self._state.<method>(self, ...). The ATM also owns the three singleton state
objects so concrete states can name them by attribute rather than by import or global.
One additional method, set_state(state: ATMState) -> None, lets concrete state classes swap
the active state on the context. It is public by necessity but should be treated as internal to
the state machine.
Card
Card is the simplest entity: a value object that pairs a physical token with the account it
unlocks. Its only field is account: Account. It has no behavior; it is passed from the entry
function to IdleState.insert_card, which stores it on the ATM context.
Here is the complete state machine and class structure before any code is written.
Try It Yourself
Before reading the implementation, build it. Implement a function atm(instructions) that
replays a sequence of commands and returns one output line per non-setup command.
Each instruction is a list of strings. The commands and their outputs are:
["account", id, pin, balance]— create an account; produces no output.["insert", id]— insert the card for that account; append"Enter PIN"or"Card already inserted".["pin", value]— submit a PIN; append"Authenticated"or"Wrong PIN".["withdraw", amount]— if authenticated and funds are available, deduct and append"Dispensed <amount>, balance <n>"; else append"Insufficient funds"or"Not authenticated".["balance"]— append"Balance: <n>"or"Not authenticated".["eject"]— append"Card ejected"and return to idle.
Run your solution against the test cases. The reference implementation follows below.