#Introduction
The interviewer says: "Design an internal ledger."
You suggest a balance column and a transaction table. Then they ask: "A transfer debits one account but crashes before crediting the other. Where did the money go? Can you prove the balance from six months ago? What happens if the client retries the same transfer after a timeout?"
That is why ledgers are built around double-entry accounting, idempotency, append-only records, and ACID transactions.
#Functional Requirements
1. Double-entry journal
- Every money movement is recorded as a journal entry
- Each journal entry has two or more ledger lines
- The sum of all line amounts in a transaction must equal zero
- Ledger lines are immutable; corrections are new reversing entries
2. Point-in-time balances
- Users and finance teams can query current balances
- They can also query balances as of a previous timestamp
- Balance reads must be fast enough for product and reporting use cases
#Non-Functional Requirements
Strict consistency
Financial data should be ACID. Ledger lines and idempotency records commit together or not at all. Eventual consistency is acceptable for read replicas and reports, but not for the source-of-truth write path.
Exactly-once posting
Every transaction requires an idempotency key. If the caller retries, the ledger returns the original result instead of posting again.
Auditability
The ledger must answer: who moved money, when, why, from which account, to which account, and under which external reference.
Hot account scalability
Popular accounts such as platform revenue, escrow, or marketplace payable accounts can receive huge write volume. Avoid a single mutable balance row as the write bottleneck.
#API Design
Post transaction
POST /api/v1/ledger/transactions
Idempotency-Key: order-123-capture
{
"externalId": "order-123",
"currency": "USD",
"lines": [
{ "accountId": "user-123-cash", "amount": -10000 },
{ "accountId": "merchant-456-pending", "amount": 9000 },
{ "accountId": "platform-fees", "amount": 1000 }
],
"metadata": {
"reason": "order_capture"
}
}
The service validates:
sum(lines.amount) = 0
all lines use the same currency
all accounts exist and can accept the entry
idempotency key has not already been posted with a different payload
Get balance
GET /api/v1/accounts/user-123-cash/balance?asOf=2026-04-20T12:00:00Z
{
"accountId": "user-123-cash",
"currency": "USD",
"balance": 42000,
"asOf": "2026-04-20T12:00:00Z",
"source": "snapshot_plus_delta"
}
#High Level Design
#Key Components
Ledger API
The only write boundary for money movement. It validates accounts, enforces idempotency, checks the sum-to-zero rule, and commits ledger lines.
Idempotency table
Stores idempotency key, payload hash, response, and status. It prevents retries from posting duplicate journal entries.
SQL ledger database
The source of truth. It stores accounts, journal entries, and ledger lines in append-only tables with transactional constraints.
Balance snapshotter
Builds periodic or event-driven balance snapshots by account. Reads use a snapshot plus newer ledger lines.
Reconciler
Checks invariants: every journal balances, account totals match external systems, and no pending transaction is stuck.
#Detailed Design
#Point-in-Time Balances
The source of truth is the ledger:
SELECT SUM(amount)
FROM ledger_lines
WHERE account_id = $1
AND created_at <= $2;
That query is correct, but it can get slow for old active accounts. Add snapshots:
balance = latest_snapshot_before(asOf) + sum(lines after snapshot up to asOf)
Snapshots are derived data. If a bug is found, they can be rebuilt from immutable ledger lines.
#Hot Account Contention
The dangerous pattern is this:
UPDATE accounts SET balance = balance + 100 WHERE id = 'platform-fees';
Every write fights for the same row lock. Prefer append-only ledger lines, then compute balances asynchronously. If product needs strongly consistent available balance, use optimistic concurrency with account versions or serialize writes for the hot account through a partitioned stream.
#Query Path
Product reads usually need current balances. Finance reads need historical balances and audit traces. Keep both paths:
- current balance: snapshot table plus recent delta
- transaction history: ledger lines by account and time
- audit report: journal entries joined to metadata and external IDs
#Common Interview Mistakes
Updating balances without immutable entries. You lose the audit trail.
Posting debit and credit separately. Ledger lines must commit atomically.
Skipping idempotency. Retries are guaranteed in distributed systems.
Treating snapshots as truth. Snapshots are caches. Ledger lines are truth.