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/transactioninternal/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:

  1. Immutable — không có setter, mọi “thay đổi” thực ra là tạo VO mới.
  2. 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ệ.
  3. 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
type Money struct {
    amount   int64  // luôn là minor unit — cent, hào — KHÔNG BAO GIỜ float64
    currency string
}

func NewMoney(amount int64, currency string) (Money, error) {
    if amount < 0 {
        return Money{}, NewDomainErrorWithDetails(ErrCodeInvalidAmount, "...", ...)
    }
    if !validCurrencies[currency] {
        return Money{}, NewDomainErrorWithDetails(ErrCodeInvalidCurrency, "...", ...)
    }
    return Money{amount: amount, currency: currency}, nil
}

func (m Money) Add(other Money) (Money, error) {
    if m.currency != other.currency {
        return Money{}, NewDomainErrorWithDetails(ErrCodeCurrencyMismatch, "...", ...)
    }
    // ...
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func NewPrivacyLevel(level string) (PrivacyLevel, error) {
    normalized := strings.ToLower(strings.TrimSpace(level))
    switch normalized {
    case shared.PrivacyHidden, shared.PrivacyBalanceOnly, shared.PrivacyFull:
        return PrivacyLevel{value: normalized}, nil
    default:
        return PrivacyLevel{}, shared.NewDomainErrorWithDetails(
            shared.ErrCodeInvalidPrivacy,
            "privacy level must be one of: hidden, balance_only, full",
            map[string]string{"privacy_level": level},
        )
    }
}

"Full"" 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ả:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
type Transaction struct {
    id          string
    coupleID    string
    userID      string
    description string
    bookedDate  time.Time
    createdAt   time.Time
    updatedAt   time.Time
}

func (t *Transaction) GetID() string          { return t.id }
func (t *Transaction) GetCoupleID() string    { return t.coupleID }
// ... chỉ toàn Get, không có Set

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.

1
2
3
4
5
6
7
type TransactionAggregate struct {
    transaction  Transaction
    money        shared.Money
    privacy      PrivacyLevel
    category     shared.Category
    domainEvents []any
}
TransactionAggregate Consistency Boundary — chỉ có 1 cửa vào Transaction (Entity) id, coupleID, userID, description, bookedDate → CÓ identity Money{amount, currency} PrivacyLevel{value} Category{name} Value Object — immutable, không id domainEvents []any TransactionCreatedEvent TransactionPrivacyChangedEvent agg.UpdatePrivacy(ctx, newLevel) ✓ duy nhất cách hợp lệ txn.money = ... ❌ field unexported, ngoài package

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func (a *TransactionAggregate) UpdatePrivacy(ctx context.Context, newLevel PrivacyLevel) error {
    if newLevel.Equals(a.privacy) {
        return nil // không đổi gì thì không publish event — tránh làm phiền Notification vô ích
    }
    oldLevel := a.privacy
    a.privacy = newLevel
    a.transaction.updatedAt = time.Now()

    a.publishEvent(TransactionPrivacyChangedEvent{
        TransactionID: a.transaction.id,
        OldPrivacy:    oldLevel.String(),
        NewPrivacy:    newLevel.String(),
        ChangedAt:     time.Now(),
    })
    return nil
}

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

1
2
3
4
5
6
7
// internal/domain/transaction/repository.go
type Repository interface {
    Save(ctx context.Context, agg *TransactionAggregate) error
    GetByID(ctx context.Context, id string) (*TransactionAggregate, error)
    GetByCoupleID(ctx context.Context, coupleID string) ([]*TransactionAggregate, error)
    Delete(ctx context.Context, id string) error
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func (s *PrivacyService) GetVisibleFields(viewer, txnOwner string, privacy PrivacyLevel) (VisibleFields, error) {
    if viewer == txnOwner {
        return VisibleFields{Amount: true, Category: true, Description: true}, nil
    }
    switch {
    case privacy.IsFull():
        return VisibleFields{Amount: true, Category: true, Description: true}, nil
    case privacy.IsBalanceOnly():
        return VisibleFields{Amount: true}, nil
    default: // IsHidden hoặc giá trị lạ — fail closed, không lộ gì cả
        return VisibleFields{}, nil
    }
}

BudgetService là domain service “lai” — vẫn thuần business logic, nhưng cần đọc từ 2 repository khác nhau (budget.Repositorytransaction.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:

1
2
3
4
5
6
7
8
func (s *BudgetService) CheckThreshold(ctx context.Context, coupleID string, category shared.Category, newAmount shared.Money) (*BudgetThresholdReachedEvent, error) {
    budgetAgg, err := s.budgetRepo.GetByCoupleIDAndCategory(ctx, coupleID, category)
    // ... không có budget cho category này -> không phải lỗi, chỉ là (nil, nil)

    transactions, _ := s.transactionRepo.GetByCoupleID(ctx, coupleID)
    // cộng dồn spend trong kỳ hiện tại, so với limit ở 3 mốc 50/80/100%
    // ...
}

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:

 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 (s *CreateTransactionApplicationService) Execute(ctx context.Context, cmd CreateTransactionCommand) (CreateTransactionResponse, error) {
    agg, err := transaction.NewTransactionAggregate(cmd.CoupleID, cmd.UserID, cmd.Amount, ...)
    if err != nil {
        return CreateTransactionResponse{}, err
    }

    newAmount, _ := shared.NewMoney(agg.GetAmount(), agg.GetCurrency())
    thresholdEvent, _ := s.budgetService.CheckThreshold(ctx, agg.GetCoupleID(), agg.GetCategory(), newAmount)

    if err := s.repo.Save(ctx, agg); err != nil {
        return CreateTransactionResponse{}, fmt.Errorf("save transaction: %w", err)
    }

    events := agg.GetDomainEvents()
    if thresholdEvent != nil {
        events = append(events, *thresholdEvent)
    }
    for _, event := range events {
        s.eventPublisher.Publish(ctx, event)
    }
    agg.ClearDomainEvents()

    return CreateTransactionResponse{TransactionID: agg.GetID(), ...}, nil
}

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:

1
2
3
func (a *TransactionAggregate) publishEvent(event any) {
    a.domainEvents = append(a.domainEvents, event)
}

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

POST /transactions → Handler.CreateTransaction Presentation ① NewTransactionAggregate(...) — validate & build Domain ② budgetService.CheckThreshold(...) — advisory Domain ③ repo.Save(ctx, agg) — persist trước Infrastructure ④ eventPublisher.Publish(...) — publish sau khi lưu Infrastructure ⑤ trả CreateTransactionResponse{...} Application HTTP 200 OK — SuccessResponse{...} Presentation

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 SUM on-read, WebSocket realtime, mock bank provider, và tách gRPC service ở Phase 4.

Đọc thêm