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)
| Question | Why it matters |
|---|---|
| Balance as materialized column or computed from entries? |
|
| Can entries ever be updated or deleted? |
|
| 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.
| Metric | Calculation | Value |
|---|---|---|
| Accounts | Given | 100M |
| Ledger entries / day | Given (assumption documented in value) | 1B |
| Balance queries / sec | Derived from daily volume ÷ 86400 (+ peak factor) | 100K |
| Posting requests / sec | Derived from daily volume ÷ 86400 (+ peak factor) | 12K |
| Storage / day | 1B entries x ~500 bytes | 500 GB |
| Storage / year | Given | ~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.
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 rowsCross-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.
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)
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-dlqFault Tolerance
| Concern | Solution |
|---|---|
| Partial posting |
|
| Duplicate posting | Idempotency key with UNIQUE constraint |
| Balance drift | Nightly reconciliation: recompute all balances from entries |
| Data loss |
|
| Immutability violation |
|
| Regulatory audit |
|
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
How helpful was this walkthrough?
Click a star to rate. We actively use this feedback to refine and update our system design content.
Discussion
Share your thoughts, ask questions, or help others.