Skip to content

Kiến trúc Double-Entry Ledger & Data Model ​

[Đã triển khai] — mô tả theo code và migration thực tế (Flyway V1–V7). Lý do đằng sau từng quyết định xem tại các ADR được dẫn link.

1. Luồng ghi một giao dịch ​

Luồng ghi một giao dịch rút tiền trong Core BankingRequest đi qua AccountController, AccountService khóa tài khoản, LedgerService ghi Transaction, bút toán và outbox event trong cùng một DB transaction. Sau commit, cache Redis bị evict và OutboxEventPublisher gửi sự kiện lên Kafka cho 4 consumer.AccountControllerPOST /accounts/{id}/withdrawAccountService.withdraw()SELECT … FOR UPDATE (ADR-007)số dư tính thẳng từ DB, không qua cacheLedgerServiceTransaction.record(): Nợ = Có (ADR-008)pullDomainEvents() → OutboxEventPostgreSQL / Oracletransactions · ledger_entries · outbox_eventsghi trong CÙNG 1 DB transaction (ADR-009)COMMITEvict cache số dưRedis · AFTER_COMMIT (ADR-010)OutboxEventPublisher@Scheduled, 5s/lầnKafkatransaction-posted-topicAudit/ComplianceidempotentFraud DetectionPoCNotificationPoCReportingPoC

2. Mô hình dữ liệu ​

Mô hình dữ liệu Core BankingBảng transactions có nhiều ledger_entries, mỗi ledger_entry thuộc một account. Giao dịch hoàn tác trỏ về giao dịch gốc. Nhóm bảng sự kiện gồm outbox_events, processed_events và compliance_records.transactionsid PKcreated_byreversal_of_ transaction_id FKledger_entriesid PKtransaction_id FKaccount_id FKentry_typeamountcurrencycreated_ataccountsid PKaccount_numberaccount_typecurrencystatuscreated_atkhông có cột balance1NN1hoàn tácSự kiện & kiểm toán (V5–V7)outbox_eventspayload JSONpublished_at: NULL = chờprocessed_eventsevent_idchống xử lý trùngcompliance_records1 dòng / LedgerEntryghi bởi Audit consumerKafka → AuditComplianceConsumer
Quan hệBản chấtÝ nghĩa
transactions → ledger_entries1:NMỗi giao dịch gồm tối thiểu 2 bút toán Nợ/Có cân bằng
ledger_entries → accountsN:1Mỗi bút toán thuộc đúng một tài khoản (khóa ngoại thật ở DB)
transactions → transactions0..1Giao dịch hoàn tác trỏ về giao dịch gốc qua reversal_of_transaction_id

Các bảng chính ​

  • accounts (V1): id (UUID), account_number (VARCHAR(20), unique), account_type, currency, status, created_at.
    • Không có cột balance — số dư luôn được tính từ ledger (ADR-006).
  • ledger_entries (V1, V3): id, account_id (FK), transaction_id, entry_type (DEBIT/CREDIT, có CHECK constraint), amount, currency (thêm ở V3, backfill từ accounts.currency), created_at.
  • transactions (V4): aggregate root của nhóm LedgerEntry, gồm created_by và reversal_of_transaction_id; backfill từ dữ liệu ledger_entries cũ.
  • outbox_events (V5): sự kiện chờ publish lên Kafka, payload JSON, cột published_at (index chỉ trên các dòng chưa publish).
  • processed_events (V6): khóa theo eventId, giúp consumer idempotent trước at-least-once delivery của Kafka.
  • compliance_records (V7): 1 dòng cho mỗi LedgerEntry — audit cần biết chính xác tài khoản nào bị ghi Nợ/Có.

Migration được viết thành 2 bộ riêng: db/migration/postgresql/ và db/migration/oracle/ (ADR-011). Trên Oracle, UUID lưu dạng RAW(16) và JSONB đổi thành JSON.


3. Quy tắc định khoản ​

Tài khoản tiền gửi của khách hàng là Nợ phải trả (Liability) của ngân hàng: tăng ghi Có, giảm ghi Nợ. Vì vậy:

text
balance = SUM(CREDIT) - SUM(DEBIT)

Ví dụ khách hàng A rút 80.00 VND (tài khoản đối ứng là counterparty):

Bút toánTài khoảnPhân loạiSố tiền
1Khách hàng ADEBIT80.00 VND
2CounterpartyCREDIT80.00 VND

deposit() ghi cặp bút toán ngược lại (CREDIT cho khách hàng, DEBIT cho đối ứng).


4. Invariant nằm ở đâu ​

InvariantNơi bảo đảm
Tổng Nợ = Tổng CóHàm dựng Transaction.record() — không thể tạo Transaction mất cân bằng
Không trộn lẫn tiền tệMoney.add()/subtract() throw CurrencyMismatchException
Scale số tiền nhất quán giữa các DBConstructor Money enforce setScale(2, RoundingMode.UNNECESSARY)
Không rút quá số dư khi đồng thờiPessimistic lock + tính số dư thẳng từ DB trong withdraw()
Bút toán không bị sửa/xóaAppend-only; hoàn tác bằng Transaction.reverse()
Không xử lý trùng sự kiệnBảng processed_events ở consumer

So sánh tổng Nợ/Có dùng BigDecimal.compareTo() chứ không dùng equals(), vì equals() coi 100.0 và 100.00 là khác nhau do khác scale.

Simulated Banking & Fintech Systems — Java & Spring Boot