Low Level Design: Ledger Service

Double-Entry Accounting Primer

Every financial event produces at least two journal lines: one debit and one credit. The sum of all debits must equal the sum of all credits for every entry, preserving the accounting equation: Assets = Liabilities + Equity.

Core Schema

Account Table

Account (
  id          BIGINT PRIMARY KEY,
  name        VARCHAR(128),
  type        ENUM('asset','liability','equity','revenue','expense'),
  currency    CHAR(3),
  created_at  TIMESTAMP
)

JournalEntry Table

JournalEntry (
  id               BIGINT PRIMARY KEY,
  description      TEXT,
  idempotency_key  VARCHAR(128) UNIQUE,
  created_at       TIMESTAMP
)

JournalLine Table

JournalLine (
  id           BIGINT PRIMARY KEY,
  entry_id     BIGINT REFERENCES JournalEntry(id),
  account_id   BIGINT REFERENCES Account(id),
  amount_cents BIGINT,
  direction    ENUM('debit','credit'),
  currency     CHAR(3)
)

Balance Rules

  • Debits increase asset and expense accounts.
  • Credits increase liability, equity, and revenue accounts.
-- Balance for an asset account
SELECT
  SUM(CASE WHEN direction = 'debit'  THEN amount_cents ELSE 0 END) -
  SUM(CASE WHEN direction = 'credit' THEN amount_cents ELSE 0 END) AS balance_cents
FROM JournalLine
WHERE account_id = ?;

Immutability and Corrections

Journal entries and lines are never updated or deleted. This preserves a complete, tamper-evident audit trail. Corrections are made via reversing entries: a new JournalEntry with the same lines but swapped directions, followed by a corrected entry.

Idempotency

The idempotency_key unique constraint on JournalEntry prevents duplicate postings. Callers generate a deterministic key (e.g., payment_{payment_id}_capture) and the database rejects re-insertion silently via INSERT IGNORE or ON CONFLICT DO NOTHING.

Multi-Currency Support

Each JournalLine stores the amount in its original currency. A parallel column stores the USD equivalent at the time of posting, sourced from an exchange rate service. Reporting queries aggregate in USD using the stored rate rather than re-converting.

Running Balance View

SELECT
  jl.account_id,
  je.created_at,
  jl.amount_cents,
  jl.direction,
  SUM(
    CASE WHEN jl.direction = 'debit' THEN jl.amount_cents ELSE -jl.amount_cents END
  ) OVER (
    PARTITION BY jl.account_id
    ORDER BY je.created_at
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
  ) AS running_balance_cents
FROM JournalLine jl
JOIN JournalEntry je ON jl.entry_id = je.id;

Period Closing

At period end, closing entries zero out revenue and expense accounts by transferring their net balance into retained earnings (an equity account). This resets P&L accounts for the next period while keeping cumulative history in the journal.

Audit Trail

Because JournalEntry is append-only and every mutation is a new entry, the full history of any account is reconstructable by replaying lines in created_at order. No separate audit log table is required.

API Design

POST /entries
{
  'idempotency_key': 'payment_42_capture',
  'description': 'Capture payment 42',
  'lines': [
    {'account_id': 101, 'direction': 'debit',  'amount_cents': 5000, 'currency': 'USD'},
    {'account_id': 202, 'direction': 'credit', 'amount_cents': 5000, 'currency': 'USD'}
  ]
}

GET /accounts/{id}/balance
GET /accounts/{id}/history?from=&to=

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “What is double-entry accounting in a ledger service?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Double-entry accounting means every financial event produces at least two journal lines: one debit and one credit. The sum of all debits must equal the sum of all credits for every entry, preserving the fundamental accounting equation: Assets = Liabilities + Equity. This constraint prevents money from being created or destroyed within the system.”
}
},
{
“@type”: “Question”,
“name”: “Why are ledger journal entries immutable?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Immutability ensures a tamper-evident audit trail required for financial compliance. Journal entries and lines are never updated or deleted. Corrections are made via reversing entries: a new journal entry with the same lines but swapped debit and credit directions, followed by a correctly-stated entry. This preserves the full history of every account balance.”
}
},
{
“@type”: “Question”,
“name”: “How do you prevent duplicate ledger entries?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Each JournalEntry has a unique idempotency_key column. Callers generate a deterministic key tied to the business event, for example 'payment_42_capture'. The database unique constraint rejects re-insertion of the same key, preventing duplicate postings even when the caller retries due to a network failure or timeout.”
}
},
{
“@type”: “Question”,
“name”: “How does a ledger service handle multiple currencies?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Each JournalLine stores the amount in its original currency alongside a USD equivalent computed at the time of posting using an exchange rate service. Reporting queries aggregate in USD using the stored rate rather than re-converting at query time, ensuring historical reports remain stable as exchange rates fluctuate.”
}
}
]
}

See also: Stripe Interview Guide 2026: Process, Bug Bash Round, and Payment Systems

See also: Coinbase Interview Guide

See also: Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering

Scroll to Top