Low-Level Design: Bank Account Transaction System (Double-Entry, Thread-Safe)

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

Scroll to Top