-
Notifications
You must be signed in to change notification settings - Fork 0
Data Model Ledger
Part of the Data Model reference. Schema:
ledger. Companion to Domain-Model, ADR-0007, ADR-0010, ADR-0011.
| Table | Role |
|---|---|
ledger.accounts |
Account master record (mutable, versioned) |
ledger.transactions |
Immutable posting envelope |
ledger.entries |
Immutable double-entry lines (range-partitioned by quarter) |
ledger.account_balances |
Incremental balance row, one per (account, currency) |
CREATE TABLE IF NOT EXISTS ledger.accounts (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
type VARCHAR(32) NOT NULL,
currency CHAR(3) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
metadata JSONB NOT NULL DEFAULT '{}',
version BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by VARCHAR(64) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by VARCHAR(64) NOT NULL,
CONSTRAINT ck_accounts_type CHECK (type IN (
'ASSET','LIABILITY','EQUITY','REVENUE','EXPENSE',
'USER_WALLET','FEE','RESERVE','SUSPENSE'
)),
CONSTRAINT ck_accounts_status CHECK (status IN ('ACTIVE','FROZEN','CLOSED')),
CONSTRAINT ck_accounts_currency CHECK (currency ~ '^[A-Z]{3}$'),
CONSTRAINT ck_accounts_name_length CHECK (char_length(name) BETWEEN 1 AND 255)
);
CREATE INDEX IF NOT EXISTS idx_accounts_currency ON ledger.accounts(currency);
CREATE INDEX IF NOT EXISTS idx_accounts_type ON ledger.accounts(type);
CREATE INDEX IF NOT EXISTS idx_accounts_status ON ledger.accounts(status);
CREATE INDEX IF NOT EXISTS idx_accounts_created ON ledger.accounts(created_at DESC);
CREATE OR REPLACE TRIGGER trg_accounts_updated_at
BEFORE UPDATE ON ledger.accounts
FOR EACH ROW EXECUTE FUNCTION shared.set_updated_at();Query patterns:
- Get account by ID: PK lookup, p99 < 1ms
- List accounts by currency / type / status: indexed, p99 < 50ms for up to 1000 results
- Update account: PK +
WHERE version = :v(optimistic locking via@Version)
Notes:
-
idis stored as a 128-bitUUIDvalue (ULID-compatible, time-sortable). The API edge layer serializes it asacc_<crockford-base32>(see ADR-0010). The prefix is never stored in the database. -
versionmaps to Hibernate@Versionfor optimistic locking. No@Versionon immutable tables (transactions, entries).
CREATE TABLE IF NOT EXISTS ledger.transactions (
id UUID PRIMARY KEY,
reference VARCHAR(255) NOT NULL,
description VARCHAR(2048),
status VARCHAR(16) NOT NULL,
reverses_id UUID,
metadata JSONB NOT NULL DEFAULT '{}',
posted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by VARCHAR(64) NOT NULL,
CONSTRAINT uq_transactions_reference UNIQUE (reference),
CONSTRAINT ck_transactions_status CHECK (status IN ('POSTED','REVERSED')),
CONSTRAINT fk_transactions_reverses FOREIGN KEY (reverses_id)
REFERENCES ledger.transactions(id) ON DELETE RESTRICT
);
CREATE INDEX IF NOT EXISTS idx_transactions_status ON ledger.transactions(status);
CREATE INDEX IF NOT EXISTS idx_transactions_posted_at ON ledger.transactions(posted_at DESC);
-- Partial unique constraint: at most one reversal per original transaction (A6)
CREATE UNIQUE INDEX IF NOT EXISTS uq_transactions_reverses_id
ON ledger.transactions(reverses_id)
WHERE reverses_id IS NOT NULL;Query patterns:
- Get transaction by ID: PK lookup
- Find by reference (idempotency): unique index
uq_transactions_reference - List recent by date:
idx_transactions_posted_at - Reversal guard:
uq_transactions_reverses_idprevents duplicate reversals at DB level
Notes:
-
transactionsis insert-only. No@Version. Status transitions are append-only (a reversal inserts a new transaction that references the original viareverses_id). - The partial unique index on
reverses_id(decision A6) ensures that each original transaction can only be reversed once, enforced without a full-table scan. -
idis exposed at the API astx_<crockford-base32>(see ADR-0010).
-- Parent partitioned table - created from migration 012 (decision A5)
CREATE TABLE IF NOT EXISTS ledger.entries (
id UUID NOT NULL,
transaction_id UUID NOT NULL,
account_id UUID NOT NULL,
amount NUMERIC(38, 18) NOT NULL,
currency CHAR(3) NOT NULL,
direction VARCHAR(8) NOT NULL,
posted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_entries PRIMARY KEY (id, created_at),
CONSTRAINT fk_entries_transaction FOREIGN KEY (transaction_id)
REFERENCES ledger.transactions(id) ON DELETE RESTRICT,
CONSTRAINT fk_entries_account FOREIGN KEY (account_id)
REFERENCES ledger.accounts(id) ON DELETE RESTRICT,
CONSTRAINT ck_entries_direction CHECK (direction IN ('DEBIT','CREDIT')),
CONSTRAINT ck_entries_currency CHECK (currency ~ '^[A-Z]{3}$'),
CONSTRAINT ck_entries_amount_nonzero CHECK (amount <> 0)
) PARTITION BY RANGE (created_at);
-- Initial partitions - created in the same migration; future partitions via pg_partman
CREATE TABLE IF NOT EXISTS ledger.entries_2026_q2 PARTITION OF ledger.entries
FOR VALUES FROM ('2026-04-01') TO ('2026-07-01');
CREATE TABLE IF NOT EXISTS ledger.entries_2026_q3 PARTITION OF ledger.entries
FOR VALUES FROM ('2026-07-01') TO ('2026-10-01');
CREATE TABLE IF NOT EXISTS ledger.entries_2026_q4 PARTITION OF ledger.entries
FOR VALUES FROM ('2026-10-01') TO ('2027-01-01');
CREATE TABLE IF NOT EXISTS ledger.entries_2027_q1 PARTITION OF ledger.entries
FOR VALUES FROM ('2027-01-01') TO ('2027-04-01');
-- Indexes on the parent are inherited by all partitions
-- Cursor pagination: (account_id, currency, created_at DESC, id DESC) - decision A9
CREATE INDEX IF NOT EXISTS idx_entries_account_cursor
ON ledger.entries(account_id, currency, created_at DESC, id DESC);
-- Time-travel (asOf) queries: WHERE posted_at <= :asOf - decision D4
CREATE INDEX IF NOT EXISTS idx_entries_account_posted
ON ledger.entries(account_id, currency, posted_at);
-- FK support
CREATE INDEX IF NOT EXISTS idx_entries_transaction_id
ON ledger.entries(transaction_id);Query patterns:
- List entries for account with cursor pagination:
idx_entries_account_cursor, p99 < 30ms for 50 rows -
asOfbalance query:SUM(amount) WHERE account_id = :id AND currency = :c AND posted_at <= :t, usesidx_entries_account_posted - Invariant check: per transaction, covered by
transaction_idindex
Notes:
- Partitioned by quarter from the first migration (decision A5). Empty future partitions cost nothing and avoid the offline rename-copy-drop migration needed to partition an existing table.
-
pg_partmancreates subsequent quarterly partitions automatically; initial partitions are pre-declared in the migration. - The composite PK includes
created_at(the partition key) because PostgreSQL requires the partition key to be part of a unique constraint on a partitioned table. -
idexposed at the API asent_<crockford-base32>(see ADR-0010).
-- Function checks sum-to-zero once per transaction at COMMIT time (decision A4)
CREATE OR REPLACE FUNCTION ledger.verify_double_entry_invariant()
RETURNS TRIGGER AS $$
DECLARE
v_bad_currency TEXT;
v_bad_sum NUMERIC(38,18);
BEGIN
-- Runs once per INSERT statement (FOR EACH STATEMENT), so we must check
-- all currencies in the transactions that were touched by this statement.
SELECT e.currency, SUM(e.amount)
INTO v_bad_currency, v_bad_sum
FROM ledger.entries e
WHERE e.transaction_id = ANY(
SELECT DISTINCT transaction_id FROM ledger.entries
-- tg_argv[0] is unused for statement-level; we aggregate all
-- entries modified in this statement via a transition table
)
GROUP BY e.currency
HAVING SUM(e.amount) <> 0
LIMIT 1;
-- Alternatively (cleaner with transition tables): see note below
SELECT e.currency, SUM(e.amount)
INTO v_bad_currency, v_bad_sum
FROM new_entries n
JOIN ledger.entries e USING (transaction_id)
GROUP BY e.transaction_id, e.currency
HAVING SUM(e.amount) <> 0
LIMIT 1;
IF v_bad_currency IS NOT NULL THEN
RAISE EXCEPTION
'double-entry invariant violated: currency=%, sum=%',
v_bad_currency, v_bad_sum
USING ERRCODE = '22000';
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-- Statement-level deferred constraint trigger - fires once per INSERT statement
-- at COMMIT time (decision A4: not per-row, to avoid partial-insert false failures)
CREATE CONSTRAINT TRIGGER trg_entries_invariant
AFTER INSERT ON ledger.entries
DEFERRABLE INITIALLY DEFERRED
FOR EACH STATEMENT
EXECUTE FUNCTION ledger.verify_double_entry_invariant();Why statement-level, not row-level:
- A row-level trigger fires after each individual
INSERT INTO entries. At that point only one leg of the double-entry exists, so the sum is always non-zero and the trigger would always raise. - A statement-level trigger fires once after all entries for the INSERT batch are written. Combined with
DEFERRABLE INITIALLY DEFERRED, it fires atCOMMITtime, after the application has inserted all legs. - This cuts one trigger invocation per entry to one invocation per posting transaction (decision A4). The latency claim in ADR-0007 should be re-benchmarked after this change.
Note on transition tables: PostgreSQL 10+ supports REFERENCING NEW TABLE AS new_entries in statement-level triggers, which is the clean way to identify which transaction_id values were touched. The migration should use that syntax; the pseudocode above is illustrative.
-- Real table, not a materialized view (ADR-0011 / decision D1)
CREATE TABLE IF NOT EXISTS ledger.account_balances (
account_id UUID NOT NULL,
currency CHAR(3) NOT NULL,
balance NUMERIC(38, 18) NOT NULL DEFAULT 0,
last_posted_at TIMESTAMPTZ NOT NULL,
version BIGINT NOT NULL DEFAULT 0,
CONSTRAINT pk_account_balances PRIMARY KEY (account_id, currency),
CONSTRAINT fk_account_balances_account FOREIGN KEY (account_id)
REFERENCES ledger.accounts(id) ON DELETE RESTRICT,
CONSTRAINT ck_account_balances_currency CHECK (currency ~ '^[A-Z]{3}$')
);Posting protocol (must run in the same transaction as the entries insert):
-- 1. Lock the balance row for the duration of the posting transaction
SELECT balance FROM ledger.account_balances
WHERE account_id = :account_id AND currency = :currency
FOR UPDATE;
-- 2. Apply the delta (sum of all entries for this account in this transaction)
INSERT INTO ledger.account_balances (account_id, currency, balance, last_posted_at, version)
VALUES (:account_id, :currency, :delta, NOW(), 0)
ON CONFLICT (account_id, currency)
DO UPDATE SET
balance = ledger.account_balances.balance + EXCLUDED.balance,
last_posted_at = EXCLUDED.last_posted_at,
version = ledger.account_balances.version + 1;Query patterns:
- Current balance: PK lookup
(account_id, currency), O(1), p99 < 5ms -
asOfbalance: NOT this table - computeSUM(entries.amount) WHERE posted_at <= :tfromentriesdirectly (decision D4)
Reconciliation:
- A scheduled job should periodically assert
account_balances.balance = SUM(entries.amount)per(account_id, currency)to catch any application-layer bugs. This is also run as a correctness test. - A full-refresh materialized view is available as an optional, off-by-default reporting mode:
-- Optional reporting-only MV (not used for live balance reads)
CREATE MATERIALIZED VIEW IF NOT EXISTS ledger.account_balances_snapshot AS
SELECT
account_id,
currency,
SUM(amount) AS balance,
MAX(created_at) AS last_entry_at,
NOW() AS computed_at
FROM ledger.entries
GROUP BY account_id, currency;
CREATE UNIQUE INDEX IF NOT EXISTS idx_account_balances_snapshot_unique
ON ledger.account_balances_snapshot(account_id, currency);
-- Refresh on demand: REFRESH MATERIALIZED VIEW CONCURRENTLY ledger.account_balances_snapshot;Design rationale: see ADR-0011. The account_balances table replaced the materialized view because MV full-refresh re-aggregates all entries on every post and cannot run inside the posting transaction. The incremental row is updated atomically in the same transaction under a SELECT ... FOR UPDATE row lock.
pg_partman handles quarterly partition creation for entries automatically once configured:
-- pg_partman configuration (run after schema creation)
SELECT partman.create_parent(
p_parent_table => 'ledger.entries',
p_control => 'created_at',
p_type => 'range',
p_interval => '3 months',
p_premake => 4 -- pre-create 4 future quarters
);Partition pruning works automatically when queries include created_at in the WHERE clause or range. Always include created_at in filters on entries for partition elimination.
| Operation | Isolation | Lock |
|---|---|---|
| Post transaction | READ COMMITTED |
SELECT ... FOR UPDATE on account_balances rows (decision D2) |
| Read balance | READ COMMITTED | none |
asOf query |
READ COMMITTED | none |
| Reverse transaction | READ COMMITTED |
SELECT ... FOR UPDATE on the original transactions row |
No REPEATABLE_READ or SERIALIZABLE required for the ledger posting path. Correctness is achieved by the FOR UPDATE row lock on the balance row plus the deferred sum-to-zero trigger.
v0.1/
010-ledger-accounts.xml
011-ledger-transactions.xml
012-ledger-entries.xml -- creates partitioned table + initial partitions
013-ledger-invariant-trigger.xml -- statement-level deferred trigger (A4)
014-ledger-balances.xml -- account_balances real table (ADR-0011)
Changelog 014 supersedes the earlier 014-ledger-balances-mv.xml (MV-based approach). If upgrading an existing schema that has the MV, a separate migration drops the MV and creates the table.
- Data-Model - conventions, ERD, cross-cutting sections
- ADR-0007 - invariant enforcement rationale
- ADR-0010 - id format at the API edge
- ADR-0011 - balance strategy
- Domain-Model - aggregate definitions
- Overview
- Services
- Data Model
- Domain Model
- Event Flow
- Security
- Observability
- Resilience
- SLA / SLI / SLO