Đây là Phần 1 của series DDD (Domain-Driven Design), nằm trong series lớn hơn tôi đang viết về Honeydue — một app quản lý tài chính cho các cặp đôi mà tôi đang tự rebuild bằng Go. Nếu bạn chưa quen khái niệm DDD, đọc Phần 0 — DDD Là Gì? Tổng Quan Cho Người Mới trước sẽ dễ theo hơn. Từ bài này trở đi, thay vì giải thích DDD kiểu hàn lâm, tôi sẽ dùng chính code trong Honeydue làm ví dụ.

Lưu ý: project vẫn đang phát triển. Bài này chỉ mô tả những gì đã implement (domain layer + application layer + infrastructure của 3 bounded context: Transaction, Budget, Couple). Chỗ nào chưa xong tôi sẽ ghi rõ, không vẽ ra thứ chưa tồn tại.

Vấn đề DDD giải quyết

Tưởng tượng bạn viết handler cho API tạo transaction, không theo DDD:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func CreateTransactionHandler(c *gin.Context) {
    var req Request
    c.ShouldBindJSON(&req)

    if req.PrivacyLevel != "hidden" && req.PrivacyLevel != "balance_only" && req.PrivacyLevel != "full" {
        c.JSON(400, gin.H{"error": "invalid privacy level"})
        return
    }
    if req.Amount < 0 {
        c.JSON(400, gin.H{"error": "invalid amount"})
        return
    }

    db.Exec("INSERT INTO transactions (...) VALUES (...)", ...)

    // tính luôn spending tháng này, kiểm tra threshold, ngay trong handler
    var spend int64
    db.QueryRow("SELECT SUM(amount) FROM transactions WHERE couple_id = ? AND category = ?", ...).Scan(&spend)
    if spend > budgetLimit*80/100 {
        sendNotification(...)
    }

    c.JSON(200, gin.H{"ok": true})
}

Code này chạy được. Vấn đề là 6 tháng sau, khi cần thêm 1 cách tạo transaction khác (ví dụ import từ bank), toàn bộ validate + business rule ở trên phải copy-paste lại, vì nó bị khoá cứng vào tầng HTTP. Business rule quan trọng nhất của cả app — “ai được thấy transaction của ai” — nằm rải rác trong handler, dễ quên, dễ sai, không test riêng được.

DDD (Eric Evans, 2003) giải quyết đúng vấn đề này: đặt toàn bộ tri thức nghiệp vụ (domain knowledge) vào một tầng riêng — domain layer — độc lập với HTTP, độc lập với SQL. Handler chỉ còn việc parse request và gọi xuống. Trong Honeydue, đoạn logic tương đương ở trên nằm gọn trong 1 domain service, test được độc lập, không cần Gin, không cần MySQL:

1
2
3
4
5
6
7
// internal/domain/transaction/service.go
func (s *PrivacyService) CanUserViewTransaction(viewer, txnOwner string, privacy PrivacyLevel) (bool, error) {
    if viewer == txnOwner {
        return true, nil
    }
    return !privacy.IsHidden(), nil
}

Ubiquitous Language — nói chung một thứ tiếng

Nguyên tắc đầu tiên của DDD: dev và người hiểu nghiệp vụ (product, hoặc chính bạn khi đóng vai “chủ sản phẩm”) phải dùng chung một từ vựng, và từ vựng đó phải xuất hiện y hệt trong code — không dịch qua dịch lại. Honeydue định nghĩa từ vựng này ngay trong CLAUDE.md:

Thuật ngữ Định nghĩa
Couple Account Account liên kết 2 user để quản lý tài chính chung
Transaction 1 khoản chi tiêu, có mức độ riêng tư
Privacy Level hidden (chỉ chủ sở hữu thấy) | balance_only (chỉ thấy số tiền) | full (thấy hết)
Budget Hạn mức chi tiêu theo category, theo tháng/năm
Budget Threshold Ngưỡng cảnh báo khi chi tiêu chạm 80% hạn mức

Không hề ngẫu nhiên mà type trong Go tên là PrivacyLevel, field tên là CoupleID, hàm tên là CheckThreshold. Khi đọc design doc nói “budget threshold”, bạn tìm thẳng ra BudgetThresholdReachedEvent trong code — không phải đoán xem “cái ngưỡng cảnh báo” đó nằm ở biến nào.

Layered Architecture — 4 tầng, 1 chiều phụ thuộc

Honeydue chia code thành 4 tầng. Luật duy nhất và quan trọng nhất: tầng ngoài phụ thuộc vào tầng trong, không bao giờ ngược lại. Domain là lõi — nó không được import bất cứ thứ gì từ Infrastructure hay Presentation.

Presentation Gin handler · middleware · request/response DTO gọi Application Command · DTO · Service — chỉ orchestration, không business logic gọi Domain Aggregate · Entity · Value Object · Domain Service Repository — chỉ là interface, không biết SQL là gì CORE — bất biến Infrastructure MySQL repository impl · Kafka publisher · Redis cache implements Repository interface Domain KHÔNG BAO GIỜ import package infrastructure

Hình 1: Layered Architecture của Honeydue — mọi mũi tên chỉ vào Domain, không bao giờ ngược lại.

Chú ý chiều mũi tên teal ở dưới: Infrastructure phụ thuộc vào Domain, chứ không phải Domain phụ thuộc Infrastructure. Repository interface được định nghĩa trong domain layer (internal/domain/transaction/repository.go), còn MysqlTransactionRepository ở infrastructure layer chỉ implement interface đó. Domain hoàn toàn không biết MySQL tồn tại. Đây là Dependency Inversion — lý do vì sao sau này có thể đổi MySQL sang Postgres, hoặc tách transaction context thành 1 service riêng mà domain layer không cần sửa 1 dòng nào.

Bounded Context — biên giới của từng mảnh nghiệp vụ

Một domain lớn (toàn bộ app Honeydue) được chia nhỏ thành các bounded context — mỗi context có model, ngôn ngữ, và trách nhiệm riêng, chỉ giao tiếp với nhau qua interface rõ ràng (domain event, hoặc gọi trực tiếp qua domain service). Honeydue hiện có 3 context CORE đã implement:

Couple Context coupleID — không gian chứa 2 user couple_id couple_id Transaction Context TransactionAggregate PrivacyLevel · PrivacyService phần khó nhất Budget Context BudgetAggregate BudgetService.CheckThreshold gọi trực tiếp, đồng bộ (chưa qua Kafka) Notification / Categorizer Worker Phase 2 — chưa implement publish qua Kafka (sẵn sàng, chưa có consumer)

Hình 2: Bản đồ 3 bounded context hiện có trong Honeydue, và phần chưa xong (khung nét đứt).

Vài điểm đáng chú ý khi đọc sơ đồ này:

  • Transaction context là “phần khó nhất” theo đúng nhận định trong design doc — không phải vì đồng bộ dữ liệu ngân hàng khó, mà vì mô hình PrivacyLevel (hidden/balance_only/full) đụng tới ranh giới nhạy cảm nhất của app: 2 người chia sẻ tài chính nhưng không phải chia sẻ mọi thứ. Bài 2 sẽ đào sâu context này.
  • Transaction → Budget hiện tại là lời gọi trực tiếp, đồng bộ, không phải qua Kafka: CreateTransactionApplicationService gọi thẳng budgetService.CheckThreshold(...) trong cùng 1 request. Đây là điều thật, không phải lý tưởng hoá — 2 context tách biệt về mặt model nhưng vẫn phối hợp trực tiếp ở tầng application, một cách hợp lý cho giai đoạn modular monolith.
  • Notification/Categorizer worker chưa tồn tại. TransactionCreatedEvent đã được publish qua Kafka publisher, nhưng chưa có consumer nào đọc nó. Đây là việc của Phase 2 theo roadmap — tôi vẽ khung nét đứt để không đánh lừa rằng nó đã chạy.

Tiếp theo

Bài này dừng ở “bức tranh lớn”: vì sao cần DDD, ngôn ngữ chung, 4 tầng, và bounded context. Bài 2 (DDD Building Blocks) sẽ đi sâu vào từng “khối xây dựng” bên trong tầng Domain — Value Object, Entity, Aggregate, Repository, Domain Service, Domain Event — toàn bộ đều lấy code thật từ internal/domain/transaction làm ví dụ, kèm sơ đồ giải phẫu 1 aggregate và luồng 1 request đi từ HTTP đến khi lưu xong.

Đọc thêm