Low-Level Design: Bank Account Transaction System
The bank account transaction system LLD models accounts, transactions, and balance management with strong consistency guarantees. It tests concurrency control, the Command pattern for transactions, audit trails, and double-entry bookkeeping. Asked at Stripe, Coinbase, Goldman Sachs, and Square.
Requirements
- Create accounts with initial balance.
- Deposit and withdraw money with atomic balance updates.
- Transfer money between accounts (two-phase operation).
- Prevent overdraft: balance cannot go below zero (unless explicitly allowed).
- Maintain a full transaction history (audit trail).
- Thread-safe: concurrent operations on the same account must not corrupt the balance.
Double-Entry Bookkeeping
In double-entry accounting, every transaction debits one account and credits another. This ensures the total sum of all account balances is always conserved. It is the foundation of all production financial systems.
from enum import Enum, auto
from dataclasses import dataclass, field
import uuid, time, threading
from typing import Optional
class EntryType(Enum):
DEBIT = auto() # money out of account
CREDIT = auto() # money into account
class TransactionStatus(Enum):
PENDING = auto()
COMPLETED = auto()
FAILED = auto()
REVERSED = auto()
@dataclass
class JournalEntry:
entry_id: str
account_id: str
entry_type: EntryType
amount: int # integer cents to avoid floating-point errors
timestamp: float
description: str
@dataclass
class Transaction:
txn_id: str
entries: list[JournalEntry]
status: TransactionStatus = TransactionStatus.PENDING
created_at: float = field(default_factory=time.time)
metadata: dict = field(default_factory=dict)
Account with Concurrency Control
class Account:
def __init__(self, account_id: str, owner_name: str,
initial_balance: int = 0, allow_overdraft: bool = False):
self.account_id = account_id
self.owner_name = owner_name
self._balance = initial_balance
self.allow_overdraft = allow_overdraft
self._lock = threading.RLock()
self._journal: list[JournalEntry] = []
@property
def balance(self) -> int:
with self._lock:
return self._balance
def _apply_entry(self, entry: JournalEntry) -> None:
"""Apply a journal entry under the account lock."""
with self._lock:
if entry.entry_type == EntryType.DEBIT:
new_balance = self._balance - entry.amount
if not self.allow_overdraft and new_balance list[JournalEntry]:
with self._lock:
return list(self._journal)
Transaction Service
class BankingService:
def __init__(self):
self.accounts: dict[str, Account] = {}
self.transactions: dict[str, Transaction] = {}
# Global lock only for account creation; per-account locks for operations
self._accounts_lock = threading.Lock()
def create_account(self, owner_name: str,
initial_balance: int = 0,
allow_overdraft: bool = False) -> Account:
with self._accounts_lock:
acct = Account(
account_id = str(uuid.uuid4())[:8],
owner_name = owner_name,
initial_balance = initial_balance,
allow_overdraft = allow_overdraft
)
self.accounts[acct.account_id] = acct
# Record initial deposit as journal entry
if initial_balance > 0:
entry = JournalEntry(
entry_id = str(uuid.uuid4())[:8],
account_id = acct.account_id,
entry_type = EntryType.CREDIT,
amount = initial_balance,
timestamp = time.time(),
description = "Initial deposit"
)
acct._journal.append(entry) # direct append, no balance change
return acct
def deposit(self, account_id: str, amount: int,
description: str = "Deposit") -> Transaction:
if amount Transaction:
if amount Transaction:
if amount <= 0:
raise ValueError("Transfer amount must be positive")
from_acct = self._get_account(from_id)
to_acct = self._get_account(to_id)
# Acquire locks in sorted order to prevent deadlock
first, second = sorted(
[(from_id, from_acct), (to_id, to_acct)],
key=lambda x: x[0]
)
debit_entry = self._make_entry(from_id, EntryType.DEBIT, amount, f"Transfer to {to_id}")
credit_entry = self._make_entry(to_id, EntryType.CREDIT, amount, f"Transfer from {from_id}")
txn = Transaction(str(uuid.uuid4())[:8], [debit_entry, credit_entry])
with first[1]._lock, second[1]._lock:
try:
# Validate before applying (read balance under lock)
projected = from_acct._balance - amount
if not from_acct.allow_overdraft and projected Transaction:
orig = self.transactions.get(txn_id)
if not orig or orig.status != TransactionStatus.COMPLETED:
raise ValueError(f"Cannot reverse transaction {txn_id}")
rev_entries = [
self._make_entry(
e.account_id,
EntryType.CREDIT if e.entry_type == EntryType.DEBIT else EntryType.DEBIT,
e.amount,
f"Reversal of {txn_id}"
)
for e in orig.entries
]
rev_txn = Transaction(str(uuid.uuid4())[:8], rev_entries)
orig.status = TransactionStatus.REVERSED
for entry in rev_entries:
self.accounts[entry.account_id]._apply_entry(entry)
rev_txn.status = TransactionStatus.COMPLETED
self.transactions[rev_txn.txn_id] = rev_txn
return rev_txn
def _get_account(self, account_id: str) -> Account:
acct = self.accounts.get(account_id)
if not acct:
raise ValueError(f"Account {account_id} not found")
return acct
def _make_entry(self, account_id, entry_type, amount, description):
return JournalEntry(
entry_id = str(uuid.uuid4())[:8],
account_id = account_id,
entry_type = entry_type,
amount = amount,
timestamp = time.time(),
description = description
)
Concurrency Design
| Concern | Solution |
|---|---|
| Concurrent deposit/withdraw on same account | Per-account RLock; all balance reads/writes under lock |
| Transfer deadlock (A transfers to B, B transfers to A) | Always acquire account locks in sorted account_id order |
| Overdraft race (check then debit) | Validate and apply balance change atomically under the lock |
| Integer vs float for money | Store amounts in integer cents; display divided by 100 |
Interview Extensions
How would you persist this to a database?
Use optimistic locking with a version field: UPDATE accounts SET balance=?, version=version+1 WHERE account_id=? AND version=?. If rows_affected=0, someone else updated concurrently — retry. Alternatively, use a database transaction (BEGIN / COMMIT) to atomically debit and credit in a transfer, relying on DB-level row locks (SELECT … FOR UPDATE in PostgreSQL).
How does double-entry bookkeeping help in production?
Every transaction debits one account and credits another by the same amount. The sum of all balances is conserved. Audits verify this invariant: sum(all balances) must equal sum(all initial deposits). Discrepancies indicate bugs or fraud. This is why financial systems never delete records — only create reversals.
{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “Why use double-entry bookkeeping in a bank account system?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Double-entry bookkeeping records every transaction as two journal entries: a DEBIT on one account and a CREDIT on another of equal amount. This means the sum of all debits always equals the sum of all credits u2014 a built-in integrity invariant. If they ever differ, data corruption has occurred. Every deposit, withdrawal, and transfer is decomposed into journal entries, creating an immutable audit trail that supports reversal and reconciliation without modifying past records.”
}
},
{
“@type”: “Question”,
“name”: “How do you prevent race conditions in a multi-threaded bank transfer?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Acquire locks on both accounts before modifying balances. To prevent deadlock from circular lock ordering (thread A locks account 1 then 2, thread B locks 2 then 1), always acquire locks in a canonical order: sorted by account ID. with lock(min_id) then lock(max_id). This guarantees both threads acquire in the same order, so no circular wait is possible. Use re-entrant locks (RLock in Python) per account, not a global lock, to allow concurrent transfers on disjoint account pairs.”
}
},
{
“@type”: “Question”,
“name”: “How do you implement transaction reversal in a banking system?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Never modify past journal entries (immutable audit log). Instead, create a compensating transaction: if the original debit was -100 from account A and +100 to account B, the reversal is +100 to account A and -100 from account B, with a reference to the original transaction ID. Set the original transaction status to REVERSED and the reversal’s status to COMPLETED. This preserves the full history and makes audits straightforward.”
}
},
{
“@type”: “Question”,
“name”: “Why store monetary values as integers (cents) instead of floats?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Floating-point arithmetic is not exact for decimal fractions: 0.1 + 0.2 = 0.30000000000000004 in IEEE 754. For financial calculations, this causes penny rounding errors that accumulate. Store amounts in the smallest currency unit (cents for USD, pence for GBP) as integers. All arithmetic is then exact integer math. Only convert to decimal for display. Alternatively, use Python’s Decimal module with explicit precision, but integer cents is simpler and faster.”
}
},
{
“@type”: “Question”,
“name”: “How would you scale a banking system to handle millions of accounts?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Shard accounts by account_id modulo N shards u2014 most transactions (single account reads, single deposits) stay within one shard. Cross-shard transfers require distributed transactions: use the Saga pattern (two-phase with compensating actions) or 2PC if your database supports it. For reads, maintain a materialized balance cache updated by an event stream (Kafka) of journal entries. For audit, store journal entries in an append-only ledger (Kafka topic or event store) separate from the balance OLTP store.”
}
}
]
}
Asked at: Stripe Interview Guide
Asked at: Coinbase Interview Guide
Asked at: Airbnb Interview Guide
Asked at: DoorDash Interview Guide