Internal Ledger System

Building an immutable double-entry ledger with idempotent posting

S
System Design Sandbox··13 min read
Learn how to design an internal ledger system. Covers double-entry journal entries, sum-to-zero validation, ACID posting, idempotency keys, point-in-time balances, snapshots, hot account contention, and reconciliation.

#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

Business Service
Ledger API
Idempotency Table
SQL Ledger DB
Balance Snapshotter
Reconciler
Finance / Reporting

#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.