Skip to content

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 ​

Write path of a withdrawal in Core BankingThe request goes through AccountController, AccountService locks the account, LedgerService writes the Transaction, its entries and an outbox event in one DB transaction. After commit the Redis cache is evicted and OutboxEventPublisher sends the event to Kafka for 4 consumers.AccountControllerPOST /accounts/{id}/withdrawAccountService.withdraw()SELECT … FOR UPDATE (ADR-007)balance read from DB, never from cacheLedgerServiceTransaction.record(): debit = credit (ADR-008)pullDomainEvents() → OutboxEventPostgreSQL / Oracletransactions · ledger_entries · outbox_eventswritten in ONE DB transaction (ADR-009)COMMITEvict balance cacheRedis · AFTER_COMMIT (ADR-010)OutboxEventPublisher@Scheduled, every 5sKafkatransaction-posted-topicAudit/ComplianceidempotentFraud DetectionPoCNotificationPoCReportingPoC

2. Data model ​

Core Banking data modelA transaction has many ledger_entries, each ledger_entry belongs to one account. A reversal transaction points to the original. The event tables are outbox_events, processed_events and compliance_records.transactionsid PKcreated_byreversal_of_ transaction_id FKledger_entriesid PKtransaction_id FKaccount_id FKentry_typeamountcurrencycreated_ataccountsid PKaccount_numberaccount_typecurrencystatuscreated_atno balance column1NN1reversalEvents & audit (V5–V7)outbox_eventsJSON payloadpublished_at: NULL = pendingprocessed_eventsevent_iddeduplicationcompliance_records1 row per LedgerEntrywritten by Audit consumerKafka → AuditComplianceConsumer
RelationshipCardinalityMeaning
transactions → ledger_entries1:NEach transaction has at least 2 balanced Debit/Credit entries
ledger_entries → accountsN:1Each entry belongs to exactly one account (real foreign key in the DB)
transactions → transactions0..1A 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 balance column — the balance is always derived from the ledger (ADR-006).
  • ledger_entries (V1, V3): id, account_id (FK), transaction_id, entry_type (DEBIT/CREDIT, with a CHECK constraint), amount, currency (added in V3, backfilled from accounts.currency), created_at.
  • transactions (V4): aggregate root for a group of LedgerEntry, with created_by and reversal_of_transaction_id; backfilled from existing ledger_entries.
  • outbox_events (V5): events waiting to be published to Kafka, JSON payload, published_at column (indexed only for unpublished rows).
  • processed_events (V6): keyed by eventId, makes consumers idempotent under Kafka's at-least-once delivery.
  • compliance_records (V7): one row per LedgerEntry — 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):

EntryAccountSideAmount
1Customer ADEBIT80.00 VND
2CounterpartyCREDIT80.00 VND

deposit() posts the opposite pair (CREDIT the customer, DEBIT the counterparty).


4. Where each invariant lives ​

InvariantEnforced by
Total Debit = total CreditTransaction.record() constructor — an unbalanced Transaction cannot be created
No currency mixingMoney.add()/subtract() throw CurrencyMismatchException
Consistent amount scale across databasesMoney constructor enforces setScale(2, RoundingMode.UNNECESSARY)
No overdraft under concurrencyPessimistic lock + balance computed directly from the DB in withdraw()
Entries are never edited or deletedAppend-only; cancellation via Transaction.reverse()
No duplicate event processingprocessed_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.

Simulated Banking & Fintech Systems — Java & Spring Boot