Definition
- 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.
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.
-- 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.
-- 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();
-- 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.
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;
}
}
# 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.
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.
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.
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.
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
┌──────────────────────────────────────────────────────────┐ │ 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.
---