Account Balance Tracker
Financial Ledger
Design accounts with deposits, withdrawals and transfers that stay correct when many threads run them at once, and never lose or invent a cent.
Overview
An account balance tracker holds a balance per account and supports three operations: deposit, withdraw, and transfer between two accounts. Each one is a few lines. The problem is that they run at the same time from many threads, and money has an invariant that concurrency loves to break: transfers move value around, they never create or destroy it, so the sum of all balances only changes when money enters or leaves the system.
The interview tests three habits. Money is stored as whole cents, never as a float, because binary floating point cannot hold most decimal fractions exactly. A balance check and the debit that follows it are one atomic step under a lock, otherwise two withdrawals can both pass the check. And a transfer takes two locks, so it must take them in a fixed order, or two opposite transfers can each hold one lock and wait forever for the other.
A strong answer gets all three right in the first version of the code, records every operation as an immutable transaction, and can show a test in which threads hammer two accounts in both directions while the total stays constant.
Scoping the problem
- What is the unit of money? — Floats drift; cents as integers do not — Integer cents
- Can a balance go negative? — Decides whether withdraw and transfer need a check — No; overdraft is refused
- Is a transfer to the same account allowed? — It would take the same lock twice — Refused
- How many threads? — Decides between one global lock and a lock per account — Many; a lock per account
- What must the history show? — A record per operation, shared between both sides of a transfer — Immutable transactions with a unique id
- Does the total need to be exact at any instant? — A consistent snapshot needs every lock at once — Yes; total_cents() holds all locks
Why the lock order matters
Deadlock needs a cycle in who waits for whom. A global order on locks makes a cycle impossible: every thread that needs both locks asks for the lower id first, so a thread holding the higher lock can never be waiting for the lower one. The PostgreSQL manual gives the same advice for database rows: the best defense against deadlocks is to acquire locks on multiple objects in a consistent order.
The invariants, and the code that keeps them
- No balance is negative — two withdrawals pass the same check — the check and the debit run under the account's lock
- Total money changes only by deposits and withdrawals — a transfer that debits but never credits — both updates happen under both locks; an exception before the debit changes nothing
- Every transaction is recorded once with a unique id — two threads take the same counter value — the id counter is read under the tracker's lock
- A recorded transaction never changes — a later fix edits history — @dataclass(frozen=True): assignment raises FrozenInstanceError
- Amounts are positive whole cents — a float or a negative slips in — type(cents) is int and cents > 0, which also rejects True
Money as integers
The Python tutorial says most decimal fractions cannot be represented exactly as binary fractions, and gives the example that 0.1 + 0.1 + 0.1 == 0.3 is False. A balance that drifts by a fraction of a cent is a balance that fails an audit. Integer cents make every operation exact and every comparison safe.
- float — False — yes, and it still drifts — never for money
- Decimal — exact — yes, chosen per operation — right when amounts have fractions of a cent, such as interest
- int cents — exact — none — the code's choice; format as dollars only at the edge
What you will be able to do
- Write deposit, withdraw and transfer so the check and the update are one locked step.
- Explain the circular-wait deadlock with two accounts and remove it with lock ordering.
- Store money as integer cents and say why floats are wrong.
- Record every operation as an immutable transaction shared by both sides of a transfer.
- Prove conservation of money with a threaded test that reads a consistent total while transfers run.
Functional requirements
- Open an account with an id and an optional starting balance; a duplicate id is refused
- Deposit a positive number of cents and record a DEPOSIT transaction
- Withdraw cents when the balance covers them, else raise InsufficientFunds and change nothing
- Transfer cents between two different accounts atomically: both balances change or neither does
- Every operation returns an immutable Transaction with a unique id; a transfer appears in both histories
- Query one balance, one history, or the total of all balances as a consistent snapshot
Non-functional requirements
- Money is integer cents; no float ever touches a balance
- Operations on different accounts run in parallel: one lock per account, not one for the tracker
- Transfers cannot deadlock: locks are always taken in account-id order
- A transaction record cannot be edited after creation
- Invalid amounts (zero, negative, non-integer, bool) are rejected before any lock is taken
Interview questions
- Show me the race in a naive withdraw, then the fix.
- Two transfers in opposite directions deadlock. Explain it and fix it.
- How do you extend transfer to a payment that touches several accounts at once?
- One global lock would be simpler. When is a lock per account worth it?
- How would this look with a database instead of in-memory locks?
Sign up free to read the full topic
More Low-Level Design topics
Start free on Cariara · Coding problems · System design studio