Back to Scenarios & Design Questions
Q5Question 5 of 67

How would you model a double-entry ledger in a relational database with DB-level correctness

Key Concepts
  • Every financial event is two or more entries whose signed amounts sum to exactly zero: money is moved, never created or destroyed.
  • Entries are append-only and immutable; corrections are new reversing transactions, never updates or deletes.
  • A deferred constraint trigger at commit time rejects any transaction whose entries do not net to zero.
  • NOT-NULL on account, amount, and currency prevents malformed entries; a non-zero CHECK rejects meaningless zero entries.
  • A unique business key on the transaction prevents duplicate postings from retries.
  • Balances are derived by summing immutable entries, not stored as a mutable column that can drift.
  • Periodic snapshots are a rebuildable performance cache, never the source of truth.

Definition

A double-entry ledger records every event as immutable, append-only entries that sum to zero, enforced by database constraints, with balances derived by summing entries and corrections made by reversing entries, so money is only moved and the books can never silently go wrong.
  • Conservation of money – Every financial event produces entries whose signed amounts sum to exactly zero, so money is only ever transferred between accounts, never created or destroyed.
  • Immutability – A posted entry is a permanent historical fact; corrections are new reversing transactions, never updates or deletes.
  • DB-level enforcement – A deferred constraint trigger rejects any commit where the entries do not net to zero, regardless of what code attempted it.
  • Idempotency – A unique business key on the transactions table prevents duplicate postings from retries.
  • Derived balances – An account's balance is the sum of its entries, not a mutable column that can drift.

Think of National Bank of India's accountant with a giant ledger book written in permanent ink. Every time money moves, the accountant writes at least two lines that must cancel out: take fifty thousand from Rajesh (minus) and give fifty thousand to Priya (plus). If a mistake is made, the accountant never erases a line; instead she writes new correcting lines. Anyone's balance is found by adding up all the lines for that person, not by trusting a scribbled running total that someone could tamper with.

Understanding the Concepts

What are the two fundamental invariants?

The first invariant is conservation of money: every financial event moves value, so the signed amounts of all entries for that event must sum to exactly zero. The second invariant is immutability: once an entry is posted it is a permanent historical fact, never updated or deleted.

JAVA
package com.bank.ledger.model;

import java.math.BigDecimal;
import java.time.Instant;

public class LedgerEntry {
    private long entryId;
    private long transactionId;
    private String accountId;    // e.g. "SAV2001" for Rajesh
    private BigDecimal amount;   // positive = credit, negative = debit
    private String currency;     // e.g. "INR"
    private Instant postedAt;

    public LedgerEntry(long transactionId, String accountId,
                       BigDecimal amount, String currency) {
        this.transactionId = transactionId;
        this.accountId = accountId;
        this.amount = amount;
        this.currency = currency;
        this.postedAt = Instant.now();
    }

    public BigDecimal getAmount() { return amount; }
    public String getAccountId() { return accountId; }
}

What does the data model look like?

Two main tables: transactions (one row per financial event) and entries (the individual lines). Each transaction has two or more entries that must sum to zero.

SQL
-- Transactions table
CREATE TABLE ledger_transactions (
    transaction_id   BIGSERIAL PRIMARY KEY,
    business_key     VARCHAR(100) NOT NULL UNIQUE, -- idempotency
    description      VARCHAR(500) NOT NULL,
    posted_at        TIMESTAMP NOT NULL DEFAULT NOW()
);

-- Entries table
CREATE TABLE ledger_entries (
    entry_id         BIGSERIAL PRIMARY KEY,
    transaction_id   BIGINT NOT NULL REFERENCES ledger_transactions(transaction_id),
    account_id       VARCHAR(50) NOT NULL,          -- e.g. SAV2001
    amount           NUMERIC(19,4) NOT NULL CHECK (amount != 0),
    currency         VARCHAR(3) NOT NULL,           -- e.g. INR
    posted_at        TIMESTAMP NOT NULL DEFAULT NOW()
);

How does the zero-sum constraint work at the DB level?

A deferred constraint trigger fires at commit time and rejects any transaction whose entries do not net to zero.

SQL
-- PostgreSQL constraint trigger
CREATE OR REPLACE FUNCTION check_zero_sum()
RETURNS TRIGGER AS $$
DECLARE
    entry_sum NUMERIC;
BEGIN
    SELECT SUM(amount) INTO entry_sum
    FROM ledger_entries
    WHERE transaction_id = NEW.transaction_id;

    IF entry_sum != 0 THEN
        RAISE EXCEPTION 'Entries for transaction % do not sum to zero: %',
            NEW.transaction_id, entry_sum;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE CONSTRAINT TRIGGER trg_zero_sum
AFTER INSERT ON ledger_entries
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION check_zero_sum();
TEXT
-- Valid: debit Rajesh 50000, credit Priya 50000
-- Sum = -50000 + 50000 = 0 -> COMMIT succeeds

-- Invalid: credit Priya 50000 with no debit
-- Sum = 50000 != 0 -> COMMIT fails with:
--   ERROR: Entries for transaction 42 do not sum to zero: 50000

How does the Java service post a balanced transfer?

All entries for one event are inserted in a single database transaction, ensuring atomicity. The zero-sum trigger fires at commit.

JAVA
package com.bank.ledger.service;

import com.bank.ledger.model.LedgerEntry;
import java.math.BigDecimal;
import java.util.List;

public class LedgerService {

    private final LedgerRepository repository;

    public LedgerService(LedgerRepository repository) {
        this.repository = repository;
    }

    public long postTransfer(String fromAccount, String toAccount,
                              BigDecimal amount, String currency,
                              String businessKey) {
        // Idempotency: unique business_key rejects duplicates
        long txnId = repository.createTransaction(businessKey, 
            "Transfer " + fromAccount + " to " + toAccount);

        // Debit (negative) and credit (positive) sum to zero
        repository.insertEntry(txnId, fromAccount, amount.negate(), currency);
        repository.insertEntry(txnId, toAccount, amount, currency);

        // On commit, the DB trigger verifies sum = 0
        return txnId;
    }
}
TEXT
# postTransfer("SAV2001", "SAV3002", 50000, "INR", "TXN-2024-001")
# Entries: SAV2001 = -50000, SAV3002 = +50000, sum = 0 -> OK
# Retry with same businessKey -> unique constraint rejects duplicate

Why are balances derived, not stored?

A stored mutable balance can drift if an update is missed, double-applied, or raced. A derived balance is always exactly consistent with the history by construction.

JAVA
package com.bank.ledger.service;

import java.math.BigDecimal;

public class BalanceService {

    private final LedgerRepository repository;

    public BalanceService(LedgerRepository repository) {
        this.repository = repository;
    }

    // CORRECT: derive balance from immutable entries
    public BigDecimal getBalance(String accountId) {
        return repository.sumEntriesForAccount(accountId);
        // SELECT SUM(amount) FROM ledger_entries WHERE account_id = ?
    }

    // For performance: snapshot + tail
    public BigDecimal getBalanceFast(String accountId) {
        BigDecimal snapshot = repository.getLatestSnapshot(accountId);
        BigDecimal tail = repository.sumEntriesSinceSnapshot(accountId);
        return snapshot.add(tail);
        // Snapshot is a rebuildable cache, not the source of truth
    }
}

How do corrections work in an append-only ledger?

You never update or delete a posted entry. You post a reversing transaction that negates the erroneous entries, then post the correct transaction.

JAVA
package com.bank.ledger.service;

import java.math.BigDecimal;

public class CorrectionService {

    private final LedgerService ledgerService;

    public CorrectionService(LedgerService ledgerService) {
        this.ledgerService = ledgerService;
    }

    public void correctTransfer(long originalTxnId, String fromAccount,
                                 String toAccount, BigDecimal wrongAmount,
                                 BigDecimal correctAmount, String currency) {
        // Step 1: Reverse the wrong transaction
        ledgerService.postTransfer(toAccount, fromAccount, wrongAmount,
            currency, "REVERSAL-" + originalTxnId);

        // Step 2: Post the correct transaction
        ledgerService.postTransfer(fromAccount, toAccount, correctAmount,
            currency, "CORRECTION-" + originalTxnId);

        // All three transactions remain in the ledger for audit
    }
}

What are common mistakes?

The most dangerous mistake is storing a mutable balance column as the source of truth instead of deriving it from entries.

JAVA
package com.bank.ledger.antipattern;

import java.math.BigDecimal;

public class MutableBalanceWrong {

    // WRONG: a bug can credit without debiting, creating money
    public void transfer(String from, String to, BigDecimal amount) {
        updateBalance(from, getBalance(from).subtract(amount));
        // If crash happens here, money vanished!
        updateBalance(to, getBalance(to).add(amount));
    }

    // WRONG: race condition between two concurrent transfers
    // Both read balance = 10000, both subtract 8000, both write 2000
    // Actual balance should be -6000 but shows 2000

    private BigDecimal getBalance(String acct) { return BigDecimal.ZERO; }
    private void updateBalance(String acct, BigDecimal bal) { }
}

What should a senior engineer know?

Keep all entries of an event in one database transaction. Enforce single-currency-per-transaction or model multi-currency carefully, because summing across currencies is meaningless. Use the unique business key because append-only makes duplicates permanent. Consider a hash chain over entries if you also need tamper-evidence.

JAVA
package com.bank.ledger.validation;

import java.math.BigDecimal;
import java.util.List;

public class EntryValidator {

    public void validateEntrySet(List<BigDecimal> amounts, List<String> currencies) {
        // All entries must use the same currency
        long distinctCurrencies = currencies.stream().distinct().count();
        if (distinctCurrencies != 1) {
            throw new IllegalArgumentException(
                "Cannot sum entries across different currencies");
        }

        // Sum must be zero
        BigDecimal sum = amounts.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
        if (sum.compareTo(BigDecimal.ZERO) != 0) {
            throw new IllegalArgumentException(
                "Entry set does not sum to zero: " + sum);
        }
    }
}

Visual Overview

DIAGRAM
┌──────────────────────────────────────────────────────────┐
│              DOUBLE-ENTRY LEDGER MODEL                    │
├──────────────────────────────────────────────────────────┤
│                                                          │
│  Transfer: Rajesh (SAV2001) → Priya (SAV3002) = 50,000  │
│                                                          │
│  ┌─────────────────────────────────────────┐             │
│  │ ledger_transactions                     │             │
│  │ ┌────────────┬───────────────────────┐  │             │
│  │ │ txn_id: 42 │ business_key: TXN-001 │  │             │
│  │ └────────────┴───────────────────────┘  │             │
│  └─────────────────────────────────────────┘             │
│                     │                                    │
│           ┌─────────▼─────────┐                          │
│           │ ledger_entries     │                          │
│  ┌────────┴───────────────────┴────────┐                 │
│  │ entry_id │ account │ amount │ curr  │                 │
│  │    101   │ SAV2001 │-50,000 │ INR   │  (debit)        │
│  │    102   │ SAV3002 │+50,000 │ INR   │  (credit)       │
│  └─────────────────────────────────────┘                 │
│                                                          │
│  SUM = -50,000 + 50,000 = 0  ✓  (trigger passes)        │
│                                                          │
│  Balance of SAV2001 = SUM(all entries for SAV2001)       │
│  Balance of SAV3002 = SUM(all entries for SAV3002)       │
│                                                          │
│  Correction: new reversing txn, never update/delete      │
└──────────────────────────────────────────────────────────┘

Key Takeaway

A double-entry ledger models every financial event as a transaction made of two or more entries whose signed amounts sum to exactly zero. This encodes the invariant that money is only transferred between accounts, never created or destroyed. At National Bank of India, the team had been bitten by exactly the anti-patterns this prevents: a bug once credited a balance column without the matching debit, creating money that did not exist, and a support engineer once edited a ledger row, destroying the audit trail.

The model is a transactions table with a unique business key and an entries table with account, amount, and currency. The database constraints are the heart of it: NOT-NULL and non-zero CHECK on amount, and a deferred constraint trigger that fires at commit and rejects any transaction whose entries do not net to zero. The unique business key prevents duplicate postings from retries, which matters precisely because the ledger is append-only and a duplicate would create a second permanent, balance-changing transaction.

Balances are derived by summing immutable entries, never stored as a mutable column. A stored balance can silently drift if an update is missed or double-applied, whereas a derived balance is always exactly consistent with the history by construction. For performance, periodic snapshots serve as a rebuildable cache. Corrections stay append-only: Pankaj posts a reversal and then the correct transaction, so the error, its reversal, and the fix all remain visible for auditors.

Remember one sentence: A double-entry ledger records every event as immutable entries that sum to zero, enforced at the database level with a deferred zero-sum trigger and a unique business key, with balances derived from entries so money is only moved and the books can never silently go wrong.

---

Share
Use ← → arrow keys to navigate