System Design Problem

Design a Distributed Banking Ledger System

Commonly Asked By:StripeBlockPayPalRevolutJPMorgan Chase

Interview Setup

Interview Prompt

Design a distributed banking ledger with double-entry bookkeeping, immutable audit trail, and 1 billion ledger entries per day.

Clarifying Questions (ask before designing)

QuestionWhy it matters
Balance as materialized column or computed from entries?
  • Computed is source of truth
  • snapshots trade storage for 100K balance queries/sec.
Can entries ever be updated or deleted?
  • Regulators require append-only
  • corrections are reversing entries.
Single currency or multi-currency?Per-currency balancing: SUM(EUR debits)=SUM(EUR credits) independently.
Cross-ledger settlement between internal books?Inter-company transfers need settlement account and T+1 reconciliation.

Scope

In scope

  • Double-entry bookkeeping
  • Immutable ledger
  • ACID postings
  • Balance snapshots
  • Regulatory audit
  • Cross-ledger settlement

Out of scope (state explicitly)

  • Fraud ML
  • Customer mobile app
  • Building core banking from scratch
  • Consumer wallet UX flows: top-up, P2P, KYC limits, and merchant checkout

Functional Requirements

Ledger + compliance focus: double-entry bookkeeping, audit trails, and regulatory reporting. For consumer wallet product flows, see Digital Wallet System (consult that guide first for P2P and top-up UX).

Start by confirming whether you are designing institutional bookkeeping or a consumer wallet, as this problem is ledger and compliance focused. Ask about chart of accounts, trial balance, and regulatory retention requirements.

  • Double-entry bookkeeping: Every transaction creates debit and credit entries that sum to zero
  • Account management: Create accounts (checking, savings, loan, revenue, expense)
  • Post transactions: Record financial transactions atomically
  • Balance inquiry: Real-time balance for any account
  • Statement generation: Account statement for any date range
  • Reconciliation: Verify all entries balance (total debits = total credits)
  • Multi-currency: Support transactions in multiple currencies with exchange rates
  • Immutable audit trail: Entries cannot be modified or deleted, only corrected via reversals

Non-Functional Requirements

Your interviewer will care most about ACID posting and append-only immutability. State the double-entry invariant early: every transaction must balance to zero, and entries are never updated, only reversed.

  • ACID Compliance: Every transaction is atomic, consistent, isolated, durable
  • Immutability: Ledger entries are append-only; no UPDATE or DELETE ever
  • Effectively-once posting: Double-entry invariants + idempotency keys: duplicate requests return the same ledger entry
  • Auditability: Complete history for regulatory compliance (7+ years retention)
  • Scale: 1B+ ledger entries/day, 100M+ accounts
  • Low Latency: Balance check < 10 ms; posting < 100 ms

Capacity Estimations

Run this math before you partition the ledger. Entries per day and account count tell you write throughput; seven-year retention drives archival and cold-storage strategy.

MetricCalculationValue
AccountsGiven100M
Ledger entries / dayGiven (assumption documented in value)1B
Balance queries / secDerived from daily volume ÷ 86400 (+ peak factor)100K
Posting requests / secDerived from daily volume ÷ 86400 (+ peak factor)12K
Storage / day1B entries x ~500 bytes500 GB
Storage / yearGiven~180 TB

Architecture Diagram

In the room: state the double-entry invariant first: every posting must balance, and trial-balance reconciliation serves as the audit backbone.

Walk your interviewer through the posting API as the choke point first. A banking ledger is the institution's books of record, contrasting with the consumer-facing wallet flows in Digital Wallet System and cross-border settlement in Multi-Currency Payment. Every financial event posts as balanced double-entry lines across a chart of accounts; balances are derived from an append-only ledger with nightly trial-balance reconciliation and 7-year regulatory retention. I draw writes as a single ACID lane and reads via materialized balance snapshots. For distributed transaction orchestration across shards, review Distributed Transactions: 2PC vs Saga.

The posting API is the choke point: each request must produce a self-balanced set of entries, update materialized balances atomically, and emit an audit event, all in a single ACID transaction under 50ms at 12K postings per second.

Loading...

Component Deep Dives

Double-Entry Transaction Posting

Next we walk through each box on the diagram. Starting with the posting service, every financial event must produce balanced entries in one ACID transaction.

The posting service is the choke point: each request must produce a self-balanced set of entries, update materialized balances atomically, and emit an audit event, all within a single transaction. Reads scale via nightly snapshots; writes scale via sharding and monthly partitions.

Unlike a consumer wallet in Digital Wallet System where the interview centers on P2P sagas and idempotency keys, banking interviews probe whether you understand institutional bookkeeping: every posting must balance per currency, map to the correct GL account, and leave an immutable audit trail regulators can replay.

Every financial event creates balanced entries:

Transfer $500 from Account A to Account B:
  Entry 1: { account: A, type: DEBIT,  amount: 500.00, tx_id: "txn-123" }
  Entry 2: { account: B, type: CREDIT, amount: 500.00, tx_id: "txn-123" }
  SUM = -500 + 500 = 0 ✓

Atomic posting (PostgreSQL transaction):
  BEGIN;
    INSERT INTO ledger_entries ... VALUES (debit), (credit);
    UPDATE account_balances SET balance = balance - 500 WHERE account_id = 'A';
    UPDATE account_balances SET balance = balance + 500 WHERE account_id = 'B';
    SELECT balance FROM account_balances WHERE account_id = 'A';
    -- If balance < 0 AND account requires non-negative: ROLLBACK
  COMMIT;

Balance Computation: Running Balance vs Calculated

The ledger entries table is the source of truth; account_balances is a performance cache updated in the same transaction as each posting. Summing all entries for a 10-year-old account on every balance query is O(n) and unusable at 100K reads per second; production systems maintain a running balance per account and reconcile nightly against a full recompute.

Approach 1: Calculated (sum all entries) - slow for old accounts
Approach 2: Running balance in separate table - O(1), < 1ms
Approach 3: Periodic checkpoint + delta - nightly batch

We use Approach 2 (running balance) for real-time + Approach 3 for reconciliation.

Immutability: How to "Fix" Errors

Regulators require an append-only audit trail with no UPDATE or DELETE operations on ledger rows, ever. When an entry is wrong, the correction is itself a new posting: a reversal entry that negates the original, followed by the correct entry. The full history remains intact for auditors to replay.

Entry is wrong. Cannot UPDATE or DELETE. How to correct?
Reversal: post a new entry that reverses the original.

Original (wrong): Charged $50 fee instead of $5
  Entry 1: { acct: customer, DEBIT, $50 }
  Entry 2: { acct: fee_rev,  CREDIT, $50 }

Reversal:
  Entry 3: { acct: customer, CREDIT, $50, reverses: entry_1 }
  Entry 4: { acct: fee_rev,  DEBIT, $50, reverses: entry_2 }

Correct posting:
  Entry 5: { acct: customer, DEBIT, $5 }
  Entry 6: { acct: fee_rev,  CREDIT, $5 }

Chart of Accounts and Trial Balance

Banks organize accounts into a chart of accounts (COA): assets, liabilities, equity, revenue, expense. Each account has a type that determines normal balance (assets/expenses debit-normal; liabilities/revenue credit-normal). A trial balance proves the books close: for each currency, SUM(debits) = SUM(credits) across all accounts. Run hourly in production; daily export for finance close.

Chart of accounts (simplified):
  1000: Customer deposits (liability)
  2000: Suspense: cross-shard transfers (asset)
  4000: Fee revenue (revenue)
  5000: Operating expenses (expense)

Trial balance check (hourly cron):
  SELECT currency,
         SUM(CASE WHEN entry_type='debit'  THEN amount ELSE 0 END) AS total_debits,
         SUM(CASE WHEN entry_type='credit' THEN amount ELSE 0 END) AS total_credits
  FROM ledger_entries
  WHERE posted_at >= NOW() - INTERVAL '1 hour'
  GROUP BY currency
  HAVING total_debits != total_credits;  -- must return 0 rows

Cross-Ledger Settlement and Regulatory Close

Large banks run multiple internal ledgers (retail, corporate, card network). Inter-company transfers post to suspense accounts on each ledger; a nightly settlement batch nets positions and posts clearing entries. Month-end close freezes postings to prior periods; corrections use reversing entries dated in the current period. Audit export API returns immutable entry streams by account and date range for 7+ years.

Cross-ledger transfer (retail → card network):
  Retail ledger:  DEBIT customer:alice $100, CREDIT suspense:outgoing $100
  Card ledger:    DEBIT suspense:incoming $100, CREDIT network:settlement $100

Nightly settlement nets suspense balances to zero across ledgers.
Regulatory export: SELECT * FROM ledger_entries WHERE account_id IN (...)
  AND posted_at BETWEEN '2019-01-01' AND '2026-03-14' ORDER BY posted_at;

API Design

Core Ledger Posting and Balance APIs

Three endpoints cover the core ledger API: atomic posting with an idempotency key, real-time balance reads from the materialized cache, and statement export for any date range. All writes require Idempotency-Key headers to survive client retries safely.

HTTP
POST /api/v1/ledger/post
Idempotency-Key: "txn-uuid-123"
{
  "transaction_id": "txn-123",
  "description": "Transfer A to B",
  "entries": [
    { "account_id": "acct-A", "type": "debit", "amount": 500.00, "currency": "USD" },
    { "account_id": "acct-B", "type": "credit", "amount": 500.00, "currency": "USD" }
  ]
}
-> 200 { "transaction_id": "txn-123", "posted_at": "...", "status": "posted" }

GET /api/v1/accounts/{account_id}/balance
-> { "account_id": "acct-A", "balance": 2450.00, "currency": "USD", "as_of": "..." }

GET /api/v1/accounts/{account_id}/statement?from=2026-03-01&to=2026-03-14
-> { "entries": [...], "opening_balance": 2950.00, "closing_balance": 2450.00 }

Common Error Responses

400 Bad Request: invalid input, missing required fields, or malformed JSON payload
401 Unauthorized: missing or invalid authentication token or API key
403 Forbidden: authenticated caller lacks required permissions for this resource
404 Not Found: requested resource ID does not exist
409 Conflict: duplicate write or version conflict, retry with a unique idempotency key
422 Unprocessable Entity: syntactically valid request failed semantic business validation
429 Too Many Requests: rate limit quota exceeded, client should honor Retry-After header
500 Internal Error: unexpected server failure, retry safely with an idempotency key
503 Service Unavailable: downstream dependency is unavailable or overloaded, retry with exponential backoff
402 Payment Required: account balance or payment method has insufficient funds
502 Bad Gateway: payment gateway provider timeout, poll transaction status endpoint

Data Model

PostgreSQL: Ledger (Partitioned by Month)

SQL
CREATE TABLE ledger_entries (
    entry_id UUID PRIMARY KEY, transaction_id UUID NOT NULL,
    account_id UUID NOT NULL, entry_type ENUM('debit','credit') NOT NULL,
    amount DECIMAL(18,2) NOT NULL CHECK (amount > 0),
    currency CHAR(3) NOT NULL DEFAULT 'USD',
    balance_after DECIMAL(18,2) NOT NULL,
    description TEXT, reverses_entry UUID,
    idempotency_key VARCHAR(64), posted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (posted_at);

CREATE TABLE ledger_entries_2026_03 PARTITION OF ledger_entries
  FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');

CREATE TABLE account_balances (
    account_id UUID PRIMARY KEY,
    balance DECIMAL(18,2) NOT NULL DEFAULT 0,
    currency CHAR(3) DEFAULT 'USD', updated_at TIMESTAMPTZ DEFAULT NOW()
);

Event Bus Design (Kafka)

Posting commits synchronously through a transactional outbox; Kafka consumers handle everything that does not need to block the 50ms posting SLO: customer notifications, analytics sinks, and nightly reconciliation batches.

Topic: ledger-events
  Partitions: 128
  Partition key: account_id
  Retention: 7 years (banking compliance)

Producer: Ledger Service via transactional outbox after ACID posting
  Event: { txn_id, account_id, entries[], balance_after, idempotency_key, timestamp }

Consumer groups:
  1. notification: txn receipts, low-balance alerts
  2. analytics: ClickHouse daily volumes, account trends, anomaly detection
  3. reconciliation: nightly batch recompute balances from entries

Sync path: double-entry posting with sum(entries)=0 in single ACID txn
Async path: notifications and analytics never block posting < 50ms
DLQ: ledger-events-dlq

Fault Tolerance

ConcernSolution
Partial posting
  • All entries in single DB transaction
  • all-or-nothing
Duplicate postingIdempotency key with UNIQUE constraint
Balance driftNightly reconciliation: recompute all balances from entries
Data loss
  • Synchronous replication
  • WAL archiving
  • point-in-time recovery
Immutability violation
  • No UPDATE/DELETE permissions
  • DB user has INSERT-only privilege
Regulatory audit
  • 7-year retention
  • partitioned tables with cold storage

Additional Considerations

Interview Walkthrough

  • 25-minute cut

    Skip arch50/arch75 depth unless staff.

    • Append-only double-entry ledger (8 min)
    • SUM(entries)=0 per currency invariant (9 min)
    • INSERT-only DB permissions on ledger table (8 min)
  • Lead with double-entry bookkeeping: every transaction produces balanced debit and credit entries, ensuring the sum of all entries is always zero.
  • Differentiate from Digital Wallet System: that article focuses on consumer product flows, whereas this design centers on institutional books of record: chart of accounts, trial balance, regulatory close, and cross-ledger settlement.
  • Explain append-only ledger_entries as immutable source of truth; account_balances is a derived cache updated in the same transaction.
  • Walk through idempotent posting: check idempotency_key → insert processed_requests → post entries + update balance atomically.
  • Cover cross-shard transfers via saga with suspense accounts, where each shard posts a self-balanced transaction and operations reconciles if a shard fails.
  • Mention pessimistic locking (SELECT ... FOR UPDATE) plus CHECK (balance >= 0) for concurrent withdrawal race conditions.
  • Discuss hot-account aggregation: batch merchant deposits in Redis, flush as a single ledger entry every 5 seconds to reduce row contention.
  • Common pitfall: storing only the balance column without an append-only ledger, because disputes and audits have no transaction history to reconstruct.

Engineering Trade-offs

Why PostgreSQL (Not Blockchain/DynamoDB)

Ledgers trade posting latency against audit completeness, comparing synchronous double-entry validation against asynchronous event sourcing.

Blockchain solves decentralized trust between untrusted parties, but a bank is the trusted authority and needs 12K+ TPS with multi-row atomic writes. DynamoDB cannot atomically post a debit and credit in one transaction. PostgreSQL gives ACID, serializable isolation, CHECK constraints, and native partitioning, providing the right foundation for a financial ledger.

Blockchain: decentralized trust, but 7-30 TPS for Bitcoin.
  Banking ledger doesn't need decentralized trust; the bank IS the trusted authority.
  PostgreSQL: 12K TPS easily.

DynamoDB: no multi-row transactions. Can't atomically post debit + credit entries.
  Financial ledger REQUIRES strong consistency and multi-row atomic writes.

PostgreSQL: ACID, serializable isolation, CHECK constraints, partitioning.
  The right tool for this job.

Cross-Shard Transfers

A single PostgreSQL transaction cannot span two shards. Cross-shard transfers use a saga with suspense accounts: each shard posts a self-balanced transaction locally, and operations reconciles suspense balances if a downstream shard fails. Stripe, Square, and PayPal all use this pattern.

Problem: Account A (Shard 3) to Account B (Shard 11).
  Single PostgreSQL transaction can't span two shards.

Solution: Saga Pattern with suspense accounts

  Shard 3: DEBIT customer:A $100, CREDIT suspense:outgoing $100
  Shard 11: DEBIT suspense:incoming $100, CREDIT customer:B $100

  Each shard has a self-consistent, balanced transaction.
  "Suspense" accounts act as intermediate holding.
  If Shard 11 fails -> retry with exponential backoff -> alert ops after 24h

  Every major payment company (Stripe, Square, PayPal) uses this pattern.

Idempotent Posting: Preventing Double Charges

Network retries are inevitable on a payment API. An idempotency key checked before insert, backed by a UNIQUE constraint on processed_requests, ensures duplicate POSTs return the original result instead of posting twice.

Flow:
  1. Check: SELECT transaction_id FROM processed_requests WHERE idempotency_key = ?
  2. INSERT INTO processed_requests -> UNIQUE constraint prevents duplicates
  3. INSERT ledger entries + UPDATE balances in same transaction
  4. Return result

The UNIQUE constraint on idempotency_key is the final safety net.

Race Condition: Concurrent Withdrawals

Two simultaneous ATM withdrawals against the same account require pessimistic row locking (SELECT ... FOR UPDATE), plus a CHECK (balance >= 0) constraint as a belt-and-suspenders guard. Hot merchant accounts that receive thousands of deposits per second batch in Redis and flush as a single ledger entry every few seconds to reduce row contention.

Account A balance: $500. Two ATM withdrawals simultaneously: $400 each.

Solution: SELECT ... FOR UPDATE (pessimistic row lock)
  Thread 1: SELECT ... FOR UPDATE -> sees 500 -> UPDATE balance = 100 -> COMMIT
  Thread 2: BLOCKS -> sees 100 -> 100 < 400 -> ROLLBACK

Additional safety: CHECK (balance >= 0) constraint

For hot accounts (merchant receiving 1000 payments/sec):
  Aggregate payments in Redis, flush as single batch entry every 5 seconds.

💬Review

Help Us Improve

How helpful was this walkthrough?

Click a star to rate. We actively use this feedback to refine and update our system design content.

Placeholder
Optional but highly appreciated!

Discussion

Share your thoughts, ask questions, or help others.

Loading comments...