Double-Entry Ledger Architecture & Data Model
[Implemented] — described from the actual code and migrations (Flyway V1–V7). The reasoning behind each decision is in the linked ADRs.
1. Write path of a transaction
2. Data model
| Relationship | Cardinality | Meaning |
|---|---|---|
transactions → ledger_entries | 1:N | Each transaction has at least 2 balanced Debit/Credit entries |
ledger_entries → accounts | N:1 | Each entry belongs to exactly one account (real foreign key in the DB) |
transactions → transactions | 0..1 | A reversal points to the original via reversal_of_transaction_id |
Main tables
accounts(V1):id(UUID),account_number(VARCHAR(20), unique),account_type,currency,status,created_at.- No
balancecolumn — the balance is always derived from the ledger (ADR-006).
- No
ledger_entries(V1, V3):id,account_id(FK),transaction_id,entry_type(DEBIT/CREDIT, with a CHECK constraint),amount,currency(added in V3, backfilled fromaccounts.currency),created_at.transactions(V4): aggregate root for a group ofLedgerEntry, withcreated_byandreversal_of_transaction_id; backfilled from existingledger_entries.outbox_events(V5): events waiting to be published to Kafka, JSON payload,published_atcolumn (indexed only for unpublished rows).processed_events(V6): keyed byeventId, makes consumers idempotent under Kafka's at-least-once delivery.compliance_records(V7): one row perLedgerEntry— audit needs to know exactly which account was debited/credited.
Migrations come in two sets: db/migration/postgresql/ and db/migration/oracle/ (ADR-011). On Oracle, UUIDs are stored as RAW(16) and JSONB becomes JSON.
3. Posting rules
A customer deposit account is a Liability of the bank: increases are credits, decreases are debits. Therefore:
text
balance = SUM(CREDIT) - SUM(DEBIT)Example: customer A withdraws 80.00 VND (the offsetting account is counterparty):
| Entry | Account | Side | Amount |
|---|---|---|---|
| 1 | Customer A | DEBIT | 80.00 VND |
| 2 | Counterparty | CREDIT | 80.00 VND |
deposit() posts the opposite pair (CREDIT the customer, DEBIT the counterparty).
4. Where each invariant lives
| Invariant | Enforced by |
|---|---|
| Total Debit = total Credit | Transaction.record() constructor — an unbalanced Transaction cannot be created |
| No currency mixing | Money.add()/subtract() throw CurrencyMismatchException |
| Consistent amount scale across databases | Money constructor enforces setScale(2, RoundingMode.UNNECESSARY) |
| No overdraft under concurrency | Pessimistic lock + balance computed directly from the DB in withdraw() |
| Entries are never edited or deleted | Append-only; cancellation via Transaction.reverse() |
| No duplicate event processing | processed_events table on the consumer side |
Debit/credit totals are compared with BigDecimal.compareTo(), not equals(), because equals() treats 100.0 and 100.00 as different due to scale.