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.