Bài trước (DDD Là Gì?) nói về bức tranh lớn: 4 tầng, bounded context. Bài này đi vào chi tiết — từng “khối xây dựng” (building block) bên trong tầng Domain, toàn bộ ví dụ lấy từ internal/domain/transaction và internal/application/transaction — context đã implement đầy đủ nhất trong Honeydue hiện tại.
Thứ tự đi từ nhỏ đến lớn: Value Object → Entity → Aggregate → Repository → Domain Service (khác Application Service ở đâu) → Domain Event.
Value Object — không có identity, chỉ có giá trị
Value Object (VO) là một khái niệm mô tả một giá trị, không phải một “thứ” có danh tính riêng. Hai VO có cùng giá trị thì coi như là một, không cần so ID. Ba đặc điểm bắt buộc:
- Immutable — không có setter, mọi “thay đổi” thực ra là tạo VO mới.
- Tự validate trong constructor — không có cách nào tạo ra một VO ở trạng thái không hợp lệ.
- So sánh bằng giá trị —
Equals(), không so con trỏ.
Money trong internal/domain/shared/value_objects.go là ví dụ kinh điển nhất — và quan trọng nhất trong 1 app fintech:
|
|
Vì sao dùng int64 thay vì float64? Số thực dấu phẩy động không biểu diễn chính xác được số thập phân — 0.1 + 0.2 trong hầu hết ngôn ngữ (kể cả Go) không ra đúng 0.3. Với tiền, sai số dù nhỏ cũng không chấp nhận được. Honeydue lưu minor unit — cent với USD, hào với VND — dưới dạng số nguyên, quy đổi hiển thị chỉ diễn ra ở tầng presentation.
Để ý Add cũng không cho cộng khác currency — nghiệp vụ “không được cộng 20 USD với 500.000 VND” nằm ngay trong value object, không thể vi phạm dù gọi từ đâu.
PrivacyLevel (trong internal/domain/transaction/value_objects.go) là VO thứ hai đáng chú ý — validate ngay khi tạo, và constructor còn tự chuẩn hoá input:
|
|
"Full" và " full " đều được chấp nhận và chuẩn hoá về "full". Nhờ vậy, mọi nơi khác trong code cầm 1 giá trị PrivacyLevel đều chắc chắn nó hợp lệ — không cần validate lại lần hai.
Entity — có identity, field thay đổi được nhưng “vẫn là nó”
Khác VO, Entity có một ID phân biệt nó với mọi entity khác, kể cả khi các field khác giống hệt nhau. Transaction (trong entity.go) là entity — nhưng chú ý: không có setter nào cả:
|
|
Field đều unexported (chữ thường). Đây là cơ chế Go dùng để ép buộc quy tắc “chỉ sửa qua aggregate root”: code ngoài package transaction không thể viết txn.description = "..." — Go sẽ báo lỗi compile, không phải lỗi runtime hay convention “đừng làm vậy”. Chỉ code cùng package (tức là TransactionAggregate, trong aggregate.go) mới gán trực tiếp được vào field này.
Aggregate — biên giới nhất quán, chỉ 1 cửa vào
Đây là khái niệm hay bị hiểu sai nhất. Aggregate không phải “một class to chứa nhiều class con” — nó là ranh giới giao dịch (consistency boundary): mọi thay đổi bên trong ranh giới đó phải xảy ra atomically, và chỉ có đúng 1 điểm vào — aggregate root.
|
|
Hình 3: Bên trong TransactionAggregate — mọi thứ trong boundary chỉ đổi được qua aggregate root.
Cách duy nhất để đổi privacy của 1 transaction là gọi agg.UpdatePrivacy(...), không phải sửa thẳng field:
|
|
Có một chi tiết dễ bị bỏ qua: bên cạnh NewTransactionAggregate (constructor cho transaction mới, publish TransactionCreatedEvent), Honeydue còn có ReconstructTransactionAggregate — dùng riêng cho repository khi load lại transaction đã tồn tại từ DB. Load lại không phải là 1 sự kiện nghiệp vụ, nên hàm này giữ nguyên id/createdAt cũ và không publish event nào — nếu lỡ dùng nhầm NewTransactionAggregate để load, mỗi lần đọc 1 transaction cũ sẽ vô tình tạo ID mới và bắn ra event “vừa tạo transaction” — sai hoàn toàn.
Repository — interface nằm trong domain, implementation nằm ngoài
|
|
Chú ý tên hàm: Save, GetByID — ngôn ngữ domain (giống thao tác trên 1 collection trong bộ nhớ), không phải INSERT, SELECT WHERE. Interface này khai báo ở domain layer; implementation thật (MysqlTransactionRepository, dùng ? placeholder, ON DUPLICATE KEY UPDATE) nằm ở internal/infrastructure/persistence — đây chính là mũi tên “implements” màu teal ở Hình 1 bài trước. Domain chỉ cần biết “có thể Save và GetByID”, không quan tâm dưới đó là MySQL hay bất cứ thứ gì khác.
Domain Service khác Application Service ở đâu?
Đây là chỗ dễ nhầm nhất khi mới học DDD. Cả hai đều là “service”, nhưng vai trò hoàn toàn khác nhau — bảng so sánh dựa trên 2 ví dụ thật trong Honeydue:
| Domain Service | Application Service | |
|---|---|---|
| Ví dụ | PrivacyService, BudgetService |
CreateTransactionApplicationService |
| Chứa gì | Business logic thật — quy tắc nghiệp vụ | Không business logic — chỉ điều phối (orchestration) |
| Khi nào cần | Logic không thuộc về 1 aggregate/VO nào (cần dữ liệu từ nhiều nguồn) | Luôn cần — là “nhạc trưởng” gọi domain theo đúng thứ tự |
| Biết gì về hạ tầng | Được phép đọc qua repository (query-only) | Biết cả repository, cả event publisher |
| Có setter/thay đổi state của DB? | Không — chỉ đọc, hoặc coordinate save qua aggregate | Không — chỉ gọi repo.Save, không tự viết SQL |
PrivacyService là domain service “thuần” — hoàn toàn không đụng repository, chỉ là 1 hàm logic:
|
|
BudgetService là domain service “lai” — vẫn thuần business logic, nhưng cần đọc từ 2 repository khác nhau (budget.Repository và transaction.Repository) để tính được. Đây chính là lý do nó phải là domain service chứ không thể là method của riêng BudgetAggregate hay TransactionAggregate — logic “kiểm tra ngưỡng” thuộc về quan hệ giữa 2 aggregate, không thuộc về aggregate nào một mình:
|
|
Lưu ý về trạng thái hiện tại: CheckThreshold tính bằng cách load toàn bộ transaction của couple rồi SUM trong Go — chưa dùng Redis counter. Đây là cách đúng và đơn giản cho MVP, nhưng sẽ đổi khi có Kafka event-driven ở Phase 2.
CreateTransactionApplicationService thì hoàn toàn khác — nó không hề tự tính privacy hay tự tính threshold, chỉ gọi đúng thứ tự rồi giao lại cho domain:
|
|
Domain Event — publish sau khi lưu, không phải trước
TransactionAggregate không tự đẩy event lên Kafka — nó chỉ gom event vào 1 slice nội bộ (domainEvents) khi có thay đổi:
|
|
Application service mới là nơi thật sự publish, và chỉ publish sau khi repo.Save thành công — nhìn lại đoạn code Execute ở trên: Save luôn chạy trước, publish luôn chạy sau. Đây không phải ngẫu nhiên. Nếu publish trước khi save (hoặc publish dù save thất bại), một consumer khác (ví dụ Budget worker sau này) có thể xử lý 1 transaction… không hề tồn tại trong DB. Comment ngay trong code cũng thẳng thắn nhìn nhận giới hạn còn lại của cách làm này:
“the transaction is already persisted at this point. A publish failure here surfaces as an error to the caller but does not roll back the save — closing that dual-write gap would need an outbox pattern, which is out of scope for this service.”
Nói cách khác: đã tránh được lỗi “publish event cho thứ chưa lưu”, nhưng chưa xử lý trường hợp lưu xong mà publish lỗi (dual-write problem kinh điển). Outbox pattern là hướng giải quyết chuẩn — chưa nằm trong scope hiện tại, ghi nhận làm việc cho sau.
Ghép lại: 1 request đi qua hết các building block
Hình 4: Một request POST /transactions đi qua đủ 4 tầng — đúng theo 5 bước trong Execute().
Mỗi khối ở Hình 4 map thẳng 1-1 với 1 khái niệm vừa học: bước ① là Aggregate factory, bước ② là Domain Service, bước ③–④ là Repository + Event publish (qua Infrastructure), bước ⑤ là Application Service gói kết quả thành DTO. Không có ô nào chứa “if privacy == …” hay raw SQL nằm sai chỗ.
Trạng thái hiện tại & sẽ mở rộng: bài này dừng ở context Transaction vì nó minh hoạ đủ mọi building block. Budget context và Couple context dùng đúng các pattern giống hệt (đã có domain layer, repository, application service, HTTP handler — xem lại Hình 2 ở bài 1). Những phần chưa có, sẽ quay lại trong các bài sau của series Honeydue: Kafka consumer thật (Categorizer, Budget worker, Notification), Redis counter thay cho
SUMon-read, WebSocket realtime, mock bank provider, và tách gRPC service ở Phase 4.
Đọc thêm
- Vaughn Vernon — Implementing Domain-Driven Design, chương 5 (Entities) và 10 (Aggregates)
- martinfowler.com/bliki/DDD_Aggregate
- martinfowler.com/bliki/ValueObject
- martinfowler.com/eaaCatalog/repository.html