Sub-Ledger Architecture

Prerequisite: The Ledger

Your fintech has one ledger table. It has 47 columns. The payments team added settlement_date and clearing_reference. The treasury team added funding_source and sweep_target. The fee team added fee_type, fee_tier, and rebate_eligible. Every schema migration touches the same table, so every team’s deploy depends on every other team’s deploy. The payments team’s index on (settlement_date, status) slows the fee team’s batch aggregation query by 340ms because the planner picks the wrong index. You are three months from an audit, and the auditor’s first question — “show me the customer funds segregated from operating funds” — requires a 14-table JOIN that takes 8 seconds to execute.

The single-ledger model works at seed stage. By Series B, the ledger has become the coupling point for every team, every deploy, and every query in the company. Sub-ledgers are the fix — not because they are architecturally elegant, but because a monolithic ledger stops scaling along the axes that matter: team autonomy, schema evolution, query isolation, and regulatory separation.

Why One Ledger Breaks Down

A monolithic ledger creates four compounding problems.

Schema bloat. Each product domain needs its own metadata on entries. Payments need clearing network references. Treasury needs counterparty bank identifiers. Fees need tier classifications. Nullable columns accumulate — by the time you have five product domains, 60% of columns on any given row are NULL. The table is wide, sparse, and impossible to reason about from the schema alone.

Query contention. The payments team runs real-time balance checks with sub-50ms latency requirements. The finance team runs end-of-day aggregation queries that scan millions of rows. On a shared table, the aggregation query’s sequential scan competes with the payments team’s index lookups for buffer pool space. Postgres autovacuum on a table with 200M rows and 50K updates per hour consumes measurable I/O. Every team’s read pattern degrades every other team’s performance.

Deployment coupling. A schema migration on the shared entries table locks deploys across all teams. Adding a column with a default value on a 200M-row Postgres table takes a ACCESS EXCLUSIVE lock — in Postgres 11+ this is instant for non-volatile defaults, but any migration that rewrites data or adds constraints blocks all writers. The payments team cannot ship a column change without coordinating with treasury, fees, and compliance.

Regulatory exposure. Customer funds must be segregated from operating funds. An auditor examining a single commingled table cannot verify segregation without understanding the application logic that tags entries. A dedicated customer funds ledger with its own table, its own access controls, and its own reconciliation makes segregation structural rather than logical.

Problem Monolithic symptom Sub-ledger fix
Schema bloat 47 columns, 60% NULL per row Each book has 12–18 purpose-specific columns
Query contention Aggregation scans evict hot pages Separate tables, separate buffer pools
Deployment coupling One migration blocks all teams Independent schema evolution per book
Regulatory exposure Segregation proved by application logic Segregation proved by database structure

Domain-Specific Books

A sub-ledger — a book — is a self-contained ledger for a single product domain. Each book has its own entries table, its own account hierarchy, its own invariants, and its own schema. The double-entry constraint holds within each book independently.

A payments company typically operates four to six books:

Customer Funds Ledger. Tracks every dollar held on behalf of customers. Entries record deposits, withdrawals, and transfers between customer accounts. The invariant: the sum of all customer account balances must equal the balance in the custodial bank account, verified daily by reconciliation against the bank statement. This book is subject to state money transmitter regulations and must demonstrate segregation from operating funds.

Payments Ledger. Records payment instructions, clearing events, settlement entries, and return processing. Entries carry network-specific metadata — ACH trace numbers, wire reference IDs, card authorization codes. The invariant: every payment instruction that reaches settled status has a corresponding entry in both the customer funds ledger (money moved) and the fee ledger (fee charged).

Fee Ledger. Captures fee assessments, rebates, and revenue recognition events. Entries include fee type, tier, calculation basis, and the transaction that triggered the fee. The invariant: fee revenue in the fee ledger reconciles to fee deductions in the customer funds ledger.

Treasury Ledger. Tracks the company’s own funds — operating accounts, reserve accounts, funding positions. Entries record sweep transactions, funding inflows, and interest accruals. This book is not customer-facing and has different access controls and retention policies.

Suspense Ledger. Holds entries that cannot yet be assigned to a destination. An inbound wire with no matching customer reference sits in suspense until operations resolves it. The invariant: every suspense entry must clear — move to a destination book — within a defined SLA (typically 24–72 hours).

┌──────────────────────────────────────────────────────────┐
│                    General Ledger (derived)               │
│    Aggregated view across all books for reporting         │
└────────┬──────────┬──────────┬──────────┬────────────────┘
         │          │          │          │
    ┌────▼───┐ ┌────▼───┐ ┌───▼────┐ ┌──▼──────┐
    │Customer│ │Payments│ │  Fee   │ │Treasury │  ...
    │ Funds  │ │        │ │        │ │         │
    └────────┘ └────────┘ └────────┘ └─────────┘

Each book owns its entries table. A cross-book movement — a fee deducted from a customer account — produces entries in both books within the same database transaction. The two books reference each other through a shared transaction_id, not through foreign keys across tables.

See The Ledger. Double-entry holds within each book. A cross-book transfer creates balanced entries in both books — the customer funds ledger debits the customer, and the fee ledger credits the fee revenue account. Each book independently balances to zero.

The Chart of Accounts at Scale

Every book has a chart of accounts — the hierarchical structure that organizes accounts by type and purpose. At small scale, the chart is flat: a list of account IDs. At scale, accounts form a tree, and the tree structure determines how balances roll up for reporting.

Assets
├── Customer Funds
│   ├── Checking
│   │   ├── Individual Checking
│   │   └── Business Checking
│   └── Savings
├── Operating
│   ├── Operating Cash
│   └── Reserve
└── Receivables
    ├── Settlement Receivable
    └── Fee Receivable

A chart of accounts with 50,000 leaf accounts and 6 levels of hierarchy stores the tree using a materialized path or a closure table. The materialized path approach — storing assets.customer_funds.checking.individual as a string column on each account — makes subtree queries efficient:

SELECT account_id, SUM(balance)
FROM accounts
WHERE path LIKE 'assets.customer_funds.%'
GROUP BY account_id;

The closure table approach — storing every ancestor-descendant pair — makes roll-up aggregations faster at the cost of O(depth) inserts per account creation:

Approach Subtree query Roll-up aggregation Insert cost
Materialized path LIKE 'prefix%' — fast with btree index Requires recursive grouping O(1)
Closure table JOIN on ancestor column Pre-computed ancestor relationships O(depth)
Nested sets Range scan — fast reads Expensive rebalancing on insert O(n) worst case

For ledgers, the closure table is the common choice. Account creation is infrequent relative to entry posting. Roll-up queries — “what is the total balance across all checking accounts?” — run constantly for reporting, dashboards, and regulatory filings. Optimizing for read-heavy roll-ups at the cost of slightly more expensive account creation is the right tradeoff.

Multi-Entity Structures

A company with multiple legal entities — a US entity, a UK entity, and a holding company — needs ledger separation at the entity level. Each legal entity has its own set of books. The holding company consolidates across entities for group-level reporting.

The data model adds an entity_id to every account and every entry. But entity isolation is not just a column filter — it carries access control implications. The UK entity’s finance team should not write entries to the US entity’s books. Row-level security in Postgres enforces this at the database level:

CREATE POLICY entity_isolation ON entries
  USING (entity_id = current_setting('app.current_entity')::uuid);

Inter-company entries arise when one entity provides services to another — a shared platform fee, a cost allocation, or an internal loan. These entries appear in both entities’ books as mirror images: the US entity records a payable, the UK entity records a receivable, for the same amount.

Elimination entries remove inter-company balances during consolidation. Without elimination, the holding company’s consolidated balance sheet double-counts the inter-company receivable and payable. The elimination process:

  1. Identify all inter-company account pairs (tagged with a counterparty entity ID).
  2. Verify the balances match — a mismatch is a reconciliation break.
  3. Post elimination entries that zero out both sides in the consolidated view.

The elimination is a reporting adjustment, not a ledger mutation. The individual entity books remain unchanged. The consolidated general ledger carries the elimination entries as a separate category, visible to auditors.

Inter-company balance mismatches are among the most common audit findings in multi-entity fintechs. Automate the matching and alert on discrepancies daily — discovering a $2M inter-company mismatch during the annual audit is a weeks-long remediation.

The General Ledger as a Derived View

The general ledger is not a source of truth. It is an aggregation layer that reads from sub-ledgers and presents a unified view for financial reporting.

In a traditional ERP, the GL is the system of record — entries are posted to the GL, and sub-ledgers are downstream views. In a fintech sub-ledger architecture, the direction is reversed. The sub-ledgers are the systems of record. The GL is a materialized view that pulls from them.

This reversal has three consequences:

The GL is eventually consistent. Sub-ledger entries post in real time. The GL updates on a schedule — every 15 minutes, every hour, or at end-of-day. Real-time business decisions (can this customer spend?) query the sub-ledger. Financial reporting (what is total revenue this month?) queries the GL. Mixing these up — querying the GL for real-time balances or querying a sub-ledger for consolidated financials — produces wrong answers.

The GL schema is standardized. Sub-ledgers have domain-specific schemas. The GL has a uniform schema — account code, period, debit amount, credit amount, entity, currency. The ETL that populates the GL maps domain-specific entries to standard GL account codes.

The GL is rebuildable. If the GL diverges from the sub-ledgers — a bug in the aggregation job, a missed entry, a timezone error in period bucketing — discard the GL data for the affected period and rebuild from sub-ledger entries. The sub-ledgers are the source of truth. The GL is a cache.

CREATE MATERIALIZED VIEW general_ledger AS
SELECT
  e.entity_id,
  a.gl_account_code,
  date_trunc('month', e.effective_date) AS period,
  SUM(CASE WHEN e.direction = 'debit' THEN e.amount ELSE 0 END) AS total_debits,
  SUM(CASE WHEN e.direction = 'credit' THEN e.amount ELSE 0 END) AS total_credits
FROM entries e
JOIN accounts a ON e.account_id = a.id
GROUP BY e.entity_id, a.gl_account_code, date_trunc('month', e.effective_date);

In practice, the GL is not a single materialized view but a pipeline. Stripe’s financial infrastructure processes entries from multiple sub-ledgers through a reconciliation and mapping layer before landing in the GL. The mapping layer handles account code translation, currency conversion, and inter-company elimination. The pipeline runs continuously, with a target lag of under 5 minutes for management reporting and exact accuracy for period-end close.

Reconciliation Breaks as Alarms

When two books disagree about a shared truth — the customer funds ledger says $1,000 was deducted but the fee ledger says $900 in fees were collected — that disagreement is a reconciliation break. A break is not a bug in the reconciliation process. A break is a signal that something upstream went wrong: a dropped entry, a partial transaction commit, a race condition in the cross-book write, or a bug in the entry-posting logic.

The reconciliation process compares corresponding entries across books using the shared transaction_id:

SELECT
  cf.transaction_id,
  cf.amount AS customer_funds_amount,
  f.amount AS fee_amount,
  cf.amount - f.amount AS break_amount
FROM customer_funds_entries cf
JOIN fee_entries f ON cf.transaction_id = f.transaction_id
WHERE cf.entry_type = 'fee_deduction'
  AND cf.amount != f.amount;

Breaks fall into three categories:

Break type Cause Resolution
Missing entry One book has the entry, the other does not Replay the failed write, or post a correcting entry
Amount mismatch Both books have the entry, amounts differ Investigate the posting logic — usually a calculation bug
Timing difference Entry posted to one book but not yet to the other Wait for the async pipeline; alert if not resolved within SLA

The operational model treats breaks like incidents. A timing difference that resolves within 60 seconds is noise. A timing difference that persists for 10 minutes is a warning. An amount mismatch or missing entry is a page.

Set thresholds based on your volume. A platform processing 100,000 transactions per day with a 0.01% break rate generates 10 breaks per day — manageable for a human queue. At 0.1%, you get 100 breaks per day and need automated resolution for timing differences. At 1%, your cross-book write path has a systemic problem.

A reconciliation process that never finds breaks is not evidence that your ledger is correct. It is evidence that your reconciliation is not checking hard enough. Every cross-book movement should be reconciled — if your break count is zero for six months, verify that the reconciliation job is actually running and comparing the right fields.

Product Team Boundaries

Sub-ledgers map to team ownership. The payments team owns the payments ledger. The treasury team owns the treasury ledger. The platform team owns the customer funds ledger. Each team controls its book’s schema, entry-posting API, and reconciliation rules.

The contract between teams is the cross-book transaction API. When the payments team needs to debit a customer account and credit the settlement receivable, it does not write directly to the customer funds ledger. It calls the customer funds ledger’s posting API with a structured request:

{
  "transaction_id": "txn_8f3a...",
  "entries": [
    {
      "book": "customer_funds",
      "account_id": "acc_12ab...",
      "direction": "debit",
      "amount": 10000,
      "currency": "USD"
    },
    {
      "book": "payments",
      "account_id": "acc_settle_recv",
      "direction": "credit",
      "amount": 10000,
      "currency": "USD"
    }
  ]
}

A centralized transaction coordinator accepts the multi-book request and executes both writes in a single database transaction (if both books share a database) or as a saga with compensating entries (if books are in separate databases). The coordinator enforces that all entries in a transaction either commit together or roll back together.

Topology Consistency Complexity When to use
Shared database, separate tables ACID — single transaction Low < 10M entries, single-region
Shared database, separate schemas ACID — single transaction Medium Schema-level isolation, same consistency
Separate databases Saga / 2PC High Multi-region, strict team isolation, or separate scaling requirements

Most fintechs start with shared database, separate tables. Stripe operates multiple ledger services but coordinates cross-service entries through an internal transaction protocol. Modern Treasury’s ledger API models sub-ledgers as separate ledgers within a single system, with cross-ledger transactions handled atomically. Fragment models sub-ledgers as separate “ledgers” in their API, each with independent schemas and account hierarchies, connected through their linked transaction primitive.

The team boundary rule: a team can read any book (with appropriate access controls) but can only write to its own book through its own posting API. Cross-book writes go through the coordinator. This prevents a team from introducing entries that violate another book’s invariants — the owning team’s API enforces its own rules.

See Executing Money Flows. The saga pattern for cross-book entries in separate databases follows the same compensation model described in the handbook — each entry has a defined compensating action if a later step fails.

Operational Reality

Sub-ledger architecture introduces operational overhead that a monolithic ledger does not have. The overhead is worth it, but you should know what you are signing up for.

Migration. Splitting a monolithic ledger into sub-ledgers is a data migration project that takes 3–6 months at a company with 100M+ entries. The migration typically runs in parallel — new entries write to both the old monolithic table and the new sub-ledger tables during a transition period, with reconciliation verifying parity. Cutover happens when the reconciliation shows zero breaks for a sustained period (typically 2+ weeks).

Monitoring. Each book needs its own health dashboard: entry posting rate, balance derivation latency, reconciliation break count, and queue depth for async entries. A monolithic ledger has one dashboard. Five books have five dashboards plus the cross-book reconciliation dashboard.

Period-end close. Month-end close in a sub-ledger architecture requires coordinating across books. All books must reach a consistent cutoff point — no book can accept entries for the closed period after the cutoff. The close sequence: freeze entries in all books for the period, run cross-book reconciliation, run GL aggregation, verify trial balance, and publish financials. The close SLA at scale is typically 3–5 business days after period end. Companies with mature sub-ledger architectures and automated reconciliation close in 1–2 days.

The monolithic ledger is simpler. It stays simpler until it doesn’t — and the transition point is not a cliff but a gradual degradation across team velocity, query performance, and audit readiness. By the time you feel the pain acutely, you are 6 months behind on the migration.