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.
Asked at: Stripe Interview Guide
Asked at: Coinbase Interview Guide
Asked at: Airbnb Interview Guide
Asked at: DoorDash Interview Guide