ADR-008: Transaction Aggregate Root cho Bất biến Debit=Credit
Context
- Trước khi refactor: invariant Debit=Credit nằm trong
LedgerService.validateBalanced(), không có Entity/Aggregate nào đại diện cho khái niệm "1 giao dịch kế toán". - Vấn đề: theo nguyên tắc DDD, invariant nên nằm trong Aggregate, không nằm trong Service.
Options Considered
Phương án A — Giữ nguyên (không tạo Transaction entity)
- Ưu: ít thay đổi, migration nhẹ.
- Nhược: khái niệm "giao dịch" mãi mãi ngầm định, không gắn thêm được business rule cấp giao dịch sau này, query trả về
List<LedgerEntry>trần không có ý nghĩa.
Phương án B — Tạo Transaction Aggregate Root chứa List<LedgerEntry>
- Ưu: invariant Debit=Credit trở thành tự nhiên của Aggregate (constructor không cho tạo
Transactionmất cân bằng), reversal có chỗ tự nhiên (reversalOfTransactionId), query trả về object có nghĩa. - Nhược: thay đổi aggregate boundary thật sự — cần bảng mới, migration backfill, sửa
LedgerServicethành orchestration mỏng hơn, rủi ro over-engineering nếu chưa có nghiệp vụ nào cần thao tác ở cấp Transaction (YAGNI).
Decision
Chọn Phương án B — vì "Debit=Credit" là invariant, và theo nguyên tắc DDD invariant nên nằm trong Aggregate chứ không nằm trong Service (Service chỉ nên orchestrate). Đánh đổi thời gian/độ phức tạp để có kiến trúc đúng nguyên tắc, chấp nhận rủi ro over-engineering ở giai đoạn hiện tại vì đây là portfolio học tập, ưu tiên thực hành đúng pattern hơn tốc độ phát triển.
Consequences
Tích cực:
- Invariant được đảm bảo tự động ở tầng domain.
LedgerServicegiờ chỉ còn orchestration.- Reversal có chỗ lưu đúng ngữ nghĩa (
reversalOfTransactionId).
Tiêu cực/rủi ro:
- Tăng độ phức tạp hệ thống khi chưa có nghiệp vụ thật sự cần Transaction là 1 object độc lập.
Vấn đề kỹ thuật phát sinh trong quá trình triển khai:
- PostgreSQL không hỗ trợ
MIN()trực tiếp trên kiểu UUID — không có thứ tự sắp xếp mặc định cho UUID để so sánh. Migration backfill (gom nhómledger_entriescũ theotransaction_idđể lấy giá trị đại diện cho mỗi nhóm) phải cast UUID sang::texttrước khiMIN(), rồi cast kết quả ngược lại::uuid. - Pitfall JPA —
Persistable<UUID>.Transaction.idđược gán thủ công trong constructor (không dùng@GeneratedValue— đúng theo nguyên tắc DDD, Aggregate tự chịu trách nhiệm tạo ra chính nó ở trạng thái hợp lệ). Vì ID không null ngay khisave()được gọi, Spring Data JPA mặc định coi entity là "đã tồn tại trong DB", gọimerge()thay vìpersist(), khiến cascadePERSISTsangLedgerEntrykhông kích hoạt đúng — sinh ra bản ghi rỗng thay vì đúng dữ liệu. Đây là lỗi "âm thầm sai" (không throw exception rõ ràng), chỉ phát hiện được khi kiểm tra kỹ dữ liệu sau khi lưu. Fix: implementPersistable<UUID>, tự định nghĩaisNew()để báo cho JPA biết chính xác đây là entity mới, bất kể ID đã có giá trị hay chưa.
Kết quả sau khi fix: 21/21 test pass, cascade hoạt động đúng. Bài học: hiểu rõ cơ chế persist vs merge của JPA quan trọng hơn nhiều so với chỉ biết cú pháp annotation.