EngineeringSenior DeveloperTraining Sessions

Session 09 - Domain-Driven Design: Chỉ Phần Thực Sự Cần

DDD có rất nhiều khái niệm. Nhưng thực tế, chỉ vài khái niệm thực sự thay đổi cách bạn code. Session này skip lý thuyết hàn lâm, focus vào 3 thứ quan trọng nhất cho code production.


Mục tiêu

  • Hiểu Aggregate - khái niệm quan trọng nhất
  • Value Object - tại sao record type thay đổi game
  • Bounded Context - tại sao mỗi module model khác nhau
  • Skip: Domain Service, Repository abstraction, Specification pattern (learn later)

1) Aggregate - Ranh Giới Consistency

Aggregate = nhóm entities phải LUÔN LUÔN consistent với nhau.

csharp
// Ví dụ: Order = Aggregate Root
// Order + OrderItems PHẢI consistent:
//   - Total = sum(items.price × quantity)
//   - Không có OrderItem mà thiếu Order
//   - Status change ảnh hưởng tất cả items

// ❌ KHÔNG có Aggregate: ai cũng sửa bất cứ thứ gì
public class OrdersController
{
    [HttpPut("items/{itemId}")]
    public async Task UpdateItem(Guid itemId, UpdateItemRequest req)
    {
        var item = await db.OrderItems.FindAsync(itemId);
        item.Quantity = req.Quantity;
        item.Price = req.Price;
        await db.SaveChangesAsync();
        // 💀 Order.Total không được recalculate!
        // 💀 OrderItem có thể thuộc Order đã cancelled!
    }
}

// ✅ CÓ Aggregate: mọi thay đổi đi qua Aggregate Root
public class Order // Aggregate Root
{
    // Private collection → bên ngoài KHÔNG sửa trực tiếp!
    private readonly List<OrderItem> _items = [];
    public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
    public Money Total { get; private set; }

    public void UpdateItemQuantity(Guid itemId, int newQuantity)
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException("Cannot modify non-draft orders");
        // ↑ Business rule: chỉ sửa khi draft

        var item = _items.FirstOrDefault(i => i.Id == itemId)
            ?? throw new DomainException("Item not found");

        item.UpdateQuantity(newQuantity); // Item validates quantity > 0
        RecalculateTotal();               // Total LUÔN consistent!
    }
}

Quy tắc Aggregate

1. Aggregate Root = ENTRY POINT duy nhất
   → Không truy cập OrderItem trực tiếp
   → Luôn qua Order: order.UpdateItemQuantity(itemId, qty)

2. Transaction boundary = 1 Aggregate
   → SaveChanges save 1 Aggregate tại 1 thời điểm
   → KHÔNG load 2 Aggregates và save cùng transaction (trừ ngoại lệ)

3. Reference giữa Aggregates = bằng ID, không bằng object
   → Order có CustomerId (Guid), KHÔNG có Customer object
   → Tại sao? Vì nếu load Customer cùng Order → transaction scope quá lớn

Kích thước Aggregate

❌ Aggregate quá LỚN:
  Order → Items → Product → Category → Supplier
  → 1 transaction load NỬA database!
  → Concurrency conflicts: 2 người sửa cùng aggregate → lock!

✅ Aggregate NHỎ, đủ dùng:
  Order → Items (chỉ ID ProductId, KHÔNG load Product entity)
  → Transaction nhỏ, ít lock, performance tốt

Rule of thumb: Aggregate chứa entities PHẢI LUÔN consistent CÙNG LÚC.
Customer info thay đổi có ảnh hưởng Order? → KHÔNG → Customer KHÔNG nằm trong Order aggregate.

2) Value Object - Tại Sao record Thay Đổi Game

Vấn đề: Primitive Obsession

csharp
// ❌ Dùng primitive types cho domain concepts
public class Order
{
    public decimal TotalAmount { get; set; }  // USD? VND? Negative ok?
    public string Currency { get; set; }       // "USD"? "usd"? "Dollar"?
    public string Street { get; set; }
    public string City { get; set; }
    public string PostalCode { get; set; }     // Validate ở đâu?
    public string Email { get; set; }          // "not-an-email" chấp nhận?
}
// → Validation phân tán, format inconsistent, compile không bắt lỗi
csharp
// ✅ Value Object: wrap primitive với behavior + validation
public record Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        if (amount < 0) throw new DomainException("Amount cannot be negative");
        Amount = amount;
        Currency = currency.ToUpperInvariant(); // Normalize!
    }

    // Behavior gắn liền với data
    public static Money operator +(Money a, Money b)
    {
        if (a.Currency != b.Currency)
            throw new DomainException("Cannot add different currencies");
        return new Money(a.Amount + b.Amount, a.Currency);
    }

    public Money ApplyDiscount(decimal percentage)
        => new(Amount * (1 - percentage / 100), Currency);
}

public record Email
{
    public string Value { get; }
    public Email(string value)
    {
        if (!value.Contains('@'))
            throw new DomainException("Invalid email");
        Value = value.ToLowerInvariant();
    }
}

Tại sao record?

csharp
// record tự động cho bạn:
// ✅ Equality by value (không phải reference)
var m1 = new Money(100, "USD");
var m2 = new Money(100, "USD");
m1 == m2; // TRUE! (record so sánh value)
// class: m1 == m2 → FALSE (so sánh reference)

// ✅ Immutable by default
// ✅ ToString() có ý nghĩa: Money { Amount = 100, Currency = USD }
// ✅ With expression: var m3 = m1 with { Amount = 200 };

// Trước C# 9: Value Object phải tự implement Equals, GetHashCode → 50 lines boilerplate
// Sau C# 9: record → 3 lines!

3) Bounded Context - "Mỗi Ngữ Cảnh, Mỗi Model"

Vấn đề: 1 model cho mọi thứ

csharp
// ❌ 1 class User cho TẤT CẢ modules
public class User
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public string Email { get; set; }
    public string Phone { get; set; }
    public Address Address { get; set; }          // Shipping cần
    public decimal CreditLimit { get; set; }      // Billing cần
    public List<string> Roles { get; set; }       // Auth cần
    public DateTime LastLogin { get; set; }        // Analytics cần
    public string TaxId { get; set; }              // Finance cần
    public byte[] Avatar { get; set; }            // Profile cần
    // → 1 class biết TẤT CẢ, ai sửa cũng ảnh hưởng tất cả
}

Bounded Context: mỗi module có model RIÊNG

Order Module:
  Customer = { Id, Name }        ← Chỉ cần biết AI đặt hàng

Shipping Module:
  Customer = { Id, Address, Phone }  ← Chỉ cần biết SHIP ĐẾN ĐÂU

Billing Module:
  Customer = { Id, CreditLimit, TaxId }  ← Chỉ cần biết THANH TOÁN

Auth Module:
  User = { Id, Email, PasswordHash, Roles }  ← Khác concept!
csharp
// Order module: chỉ dùng CustomerId
public class Order
{
    public Guid CustomerId { get; private set; }  // ID only! Không load Customer entity
    // Nếu cần tên → query riêng hoặc denormalized field
    public string CustomerName { get; private set; }
}

// Shipping module: model riêng cho delivery
public class DeliveryRecipient
{
    public Guid CustomerId { get; set; }
    public Address DeliveryAddress { get; set; }
    public string Phone { get; set; }
}

Key insight: CÙNG 1 concept thực tế (người dùng), nhưng mỗi bounded context có model khác nhau vì cần data khác nhau và behavior khác nhau.


4) Khi Nào Cần DDD?

✅ NEED DDD:
  - Domain PHỨC TẠP (insurance, finance, logistics, ecommerce)
  - Business rules NHIỀU và THAY ĐỔI thường xuyên
  - Team CÓ domain expert để hợp tác

❌ SKIP DDD:
  - CRUD app (quản lý danh sách, settings)
  - Data pipeline / ETL
  - Prototype / MVP
  - Không có domain complexity

→ DDD cho CORE domain (competitive advantage)
→ CRUD cho supporting/generic subdomains (quản lý user, settings)
→ Cùng 1 project có thể MIX cả hai!

Master Checklist ✅

□ Entity có private setters? (encapsulation)
□ Modification đi qua Aggregate Root? (consistency)
□ Reference giữa Aggregates bằng ID? (không object)
□ Primitive obsession → Value Object? (Money, Email, Address)
□ Value Object dùng record? (equality by value)
□ Bounded contexts model khác nhau cho cùng concept?
□ DDD chỉ cho complex domain? (CRUD cho simple!)

Tư duy master: DDD không phải "mọi entity phải có behavior". DDD là "đặt business rules vào đúng chỗ" - nếu entity không có rules, nó chỉ là data holder, và KHÔNG CẦN rich model.