481 lines
15 KiB
Markdown
481 lines
15 KiB
Markdown
---
|
|
name: domain-driven-design
|
|
description: DDD patterns cho complex business logic. Use for Aggregates, Value Objects, Entities, Domain Events, và Rich Domain Model.
|
|
compatibility: ".NET 8+, EF Core 8+"
|
|
metadata:
|
|
author: Velik Ho
|
|
version: "1.0"
|
|
---
|
|
|
|
# Domain-Driven Design Patterns / Mẫu DDD
|
|
|
|
DDD patterns cho GoodGo microservices với complex business logic.
|
|
|
|
## When to Use This Skill / Khi Nào Sử Dụng
|
|
|
|
Use this skill when:
|
|
- Modeling complex business domains / Mô hình hóa domain phức tạp
|
|
- Designing aggregates and entities / Thiết kế aggregates và entities
|
|
- Implementing business rules in domain / Triển khai business rules trong domain
|
|
- Creating value objects / Tạo value objects
|
|
- Raising domain events / Raise domain events
|
|
|
|
## Core Concepts / Khái Niệm Cốt Lõi
|
|
|
|
### DDD Building Blocks / Các Khối Xây Dựng DDD
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ DOMAIN MODEL LAYER │
|
|
├─────────────────────────────────────────────────────────────┤
|
|
│ ┌─────────────────────────────────────────────────────┐ │
|
|
│ │ AGGREGATE ROOT │ │
|
|
│ │ ┌─────────────────┐ ┌─────────────────┐ │ │
|
|
│ │ │ Entity │ │ Value Object │ │ │
|
|
│ │ │ (Identity) │ │ (No Identity) │ │ │
|
|
│ │ └─────────────────┘ └─────────────────┘ │ │
|
|
│ │ │ │
|
|
│ │ • Business Rules / Quy tắc nghiệp vụ │ │
|
|
│ │ • Domain Events / Domain Events │ │
|
|
│ │ • Invariants / Bất biến │ │
|
|
│ └─────────────────────────────────────────────────────┘ │
|
|
│ │
|
|
│ ┌─────────────────┐ ┌─────────────────┐ │
|
|
│ │ Domain Service │ │ Domain Events │ │
|
|
│ └─────────────────┘ └─────────────────┘ │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### Entity vs Value Object / Entity vs Value Object
|
|
|
|
| Aspect | Entity | Value Object |
|
|
|--------|--------|--------------|
|
|
| **Identity** | Has unique ID | No identity |
|
|
| **Equality** | By ID | By all properties |
|
|
| **Mutability** | Mutable (via methods) | Immutable |
|
|
| **Lifecycle** | Independent | Belongs to Entity |
|
|
| **Example** | Order, User | Address, Money |
|
|
|
|
### Aggregate Rules / Quy Tắc Aggregate
|
|
|
|
1. **One repository per aggregate root** / Một repository cho mỗi aggregate root
|
|
2. **Reference only by ID** / Chỉ tham chiếu qua ID
|
|
3. **Atomic transaction boundary** / Ranh giới transaction atomic
|
|
4. **Consistency within aggregate** / Nhất quán trong aggregate
|
|
|
|
## Key Patterns / Mẫu Chính
|
|
|
|
### Entity Base Class
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Base class for all entities.
|
|
/// VI: Base class cho tất cả entities.
|
|
/// </summary>
|
|
public abstract class Entity
|
|
{
|
|
private int? _requestedHashCode;
|
|
private List<IDomainEvent>? _domainEvents;
|
|
|
|
public virtual Guid Id { get; protected set; }
|
|
|
|
public IReadOnlyCollection<IDomainEvent> DomainEvents
|
|
=> _domainEvents?.AsReadOnly() ?? Array.Empty<IDomainEvent>().AsReadOnly();
|
|
|
|
public void AddDomainEvent(IDomainEvent eventItem)
|
|
{
|
|
_domainEvents ??= new List<IDomainEvent>();
|
|
_domainEvents.Add(eventItem);
|
|
}
|
|
|
|
public void RemoveDomainEvent(IDomainEvent eventItem)
|
|
{
|
|
_domainEvents?.Remove(eventItem);
|
|
}
|
|
|
|
public void ClearDomainEvents()
|
|
{
|
|
_domainEvents?.Clear();
|
|
}
|
|
|
|
public bool IsTransient()
|
|
{
|
|
return Id == default;
|
|
}
|
|
|
|
public override bool Equals(object? obj)
|
|
{
|
|
if (obj is not Entity other)
|
|
return false;
|
|
|
|
if (ReferenceEquals(this, other))
|
|
return true;
|
|
|
|
if (GetType() != other.GetType())
|
|
return false;
|
|
|
|
if (IsTransient() || other.IsTransient())
|
|
return false;
|
|
|
|
return Id.Equals(other.Id);
|
|
}
|
|
|
|
public override int GetHashCode()
|
|
{
|
|
if (!IsTransient())
|
|
{
|
|
_requestedHashCode ??= Id.GetHashCode() ^ 31;
|
|
return _requestedHashCode.Value;
|
|
}
|
|
return base.GetHashCode();
|
|
}
|
|
|
|
public static bool operator ==(Entity? left, Entity? right)
|
|
{
|
|
return left?.Equals(right) ?? right is null;
|
|
}
|
|
|
|
public static bool operator !=(Entity? left, Entity? right)
|
|
{
|
|
return !(left == right);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Aggregate Root
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Marker interface for aggregate roots.
|
|
/// VI: Interface đánh dấu aggregate roots.
|
|
/// </summary>
|
|
public interface IAggregateRoot { }
|
|
|
|
/// <summary>
|
|
/// EN: Order aggregate root with business rules.
|
|
/// VI: Order aggregate root với business rules.
|
|
/// </summary>
|
|
public class Order : Entity, IAggregateRoot
|
|
{
|
|
private readonly List<OrderItem> _orderItems = new();
|
|
|
|
public string UserId { get; private set; }
|
|
public Address ShippingAddress { get; private set; }
|
|
public OrderStatus Status { get; private set; }
|
|
public decimal TotalAmount { get; private set; }
|
|
public DateTime CreatedAt { get; private set; }
|
|
public DateTime? SubmittedAt { get; private set; }
|
|
|
|
public IReadOnlyCollection<OrderItem> OrderItems => _orderItems.AsReadOnly();
|
|
|
|
// EN: Required by EF Core
|
|
private Order() { }
|
|
|
|
public Order(string userId, Address shippingAddress)
|
|
{
|
|
Id = Guid.NewGuid();
|
|
UserId = userId ?? throw new ArgumentNullException(nameof(userId));
|
|
ShippingAddress = shippingAddress ?? throw new ArgumentNullException(nameof(shippingAddress));
|
|
Status = OrderStatus.Draft;
|
|
CreatedAt = DateTime.UtcNow;
|
|
TotalAmount = 0;
|
|
|
|
// EN: Raise domain event
|
|
// VI: Raise domain event
|
|
AddDomainEvent(new OrderCreatedDomainEvent(Id, userId));
|
|
}
|
|
|
|
public void AddItem(Guid productId, int quantity, decimal unitPrice)
|
|
{
|
|
// EN: Business rule: Can only add items to draft orders
|
|
// VI: Quy tắc: Chỉ thêm items vào orders draft
|
|
if (Status != OrderStatus.Draft)
|
|
throw new DomainException("Cannot add items to non-draft order");
|
|
|
|
if (quantity <= 0)
|
|
throw new ArgumentException("Quantity must be positive", nameof(quantity));
|
|
|
|
if (unitPrice < 0)
|
|
throw new ArgumentException("Price cannot be negative", nameof(unitPrice));
|
|
|
|
var existingItem = _orderItems.FirstOrDefault(i => i.ProductId == productId);
|
|
if (existingItem != null)
|
|
{
|
|
existingItem.IncreaseQuantity(quantity);
|
|
}
|
|
else
|
|
{
|
|
_orderItems.Add(new OrderItem(productId, quantity, unitPrice));
|
|
}
|
|
|
|
RecalculateTotal();
|
|
}
|
|
|
|
public void RemoveItem(Guid productId)
|
|
{
|
|
if (Status != OrderStatus.Draft)
|
|
throw new DomainException("Cannot remove items from non-draft order");
|
|
|
|
var item = _orderItems.FirstOrDefault(i => i.ProductId == productId);
|
|
if (item != null)
|
|
{
|
|
_orderItems.Remove(item);
|
|
RecalculateTotal();
|
|
}
|
|
}
|
|
|
|
public void Submit()
|
|
{
|
|
// EN: Business rule: Cannot submit empty order
|
|
// VI: Quy tắc: Không thể submit order trống
|
|
if (!_orderItems.Any())
|
|
throw new DomainException("Cannot submit empty order");
|
|
|
|
if (Status != OrderStatus.Draft)
|
|
throw new DomainException($"Cannot submit order in {Status} status");
|
|
|
|
Status = OrderStatus.Submitted;
|
|
SubmittedAt = DateTime.UtcNow;
|
|
|
|
AddDomainEvent(new OrderSubmittedDomainEvent(Id, UserId, TotalAmount));
|
|
}
|
|
|
|
public void Cancel(string reason)
|
|
{
|
|
if (Status == OrderStatus.Shipped || Status == OrderStatus.Delivered)
|
|
throw new DomainException("Cannot cancel shipped or delivered order");
|
|
|
|
Status = OrderStatus.Cancelled;
|
|
AddDomainEvent(new OrderCancelledDomainEvent(Id, reason));
|
|
}
|
|
|
|
private void RecalculateTotal()
|
|
{
|
|
TotalAmount = _orderItems.Sum(i => i.Quantity * i.UnitPrice);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Value Object
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Base class for value objects.
|
|
/// VI: Base class cho value objects.
|
|
/// </summary>
|
|
public abstract class ValueObject
|
|
{
|
|
protected abstract IEnumerable<object?> GetEqualityComponents();
|
|
|
|
public override bool Equals(object? obj)
|
|
{
|
|
if (obj is null || obj.GetType() != GetType())
|
|
return false;
|
|
|
|
var other = (ValueObject)obj;
|
|
return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
|
|
}
|
|
|
|
public override int GetHashCode()
|
|
{
|
|
return GetEqualityComponents()
|
|
.Select(x => x?.GetHashCode() ?? 0)
|
|
.Aggregate((x, y) => x ^ y);
|
|
}
|
|
|
|
public static bool operator ==(ValueObject? left, ValueObject? right)
|
|
{
|
|
return left?.Equals(right) ?? right is null;
|
|
}
|
|
|
|
public static bool operator !=(ValueObject? left, ValueObject? right)
|
|
{
|
|
return !(left == right);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Address value object.
|
|
/// VI: Value object địa chỉ.
|
|
/// </summary>
|
|
public class Address : ValueObject
|
|
{
|
|
public string Street { get; }
|
|
public string City { get; }
|
|
public string State { get; }
|
|
public string PostalCode { get; }
|
|
public string Country { get; }
|
|
|
|
public Address(string street, string city, string state, string postalCode, string country)
|
|
{
|
|
Street = street ?? throw new ArgumentNullException(nameof(street));
|
|
City = city ?? throw new ArgumentNullException(nameof(city));
|
|
State = state ?? throw new ArgumentNullException(nameof(state));
|
|
PostalCode = postalCode ?? throw new ArgumentNullException(nameof(postalCode));
|
|
Country = country ?? throw new ArgumentNullException(nameof(country));
|
|
}
|
|
|
|
protected override IEnumerable<object?> GetEqualityComponents()
|
|
{
|
|
yield return Street;
|
|
yield return City;
|
|
yield return State;
|
|
yield return PostalCode;
|
|
yield return Country;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Money value object with currency.
|
|
/// VI: Value object tiền tệ.
|
|
/// </summary>
|
|
public class Money : ValueObject
|
|
{
|
|
public decimal Amount { get; }
|
|
public string Currency { get; }
|
|
|
|
public Money(decimal amount, string currency)
|
|
{
|
|
if (amount < 0)
|
|
throw new ArgumentException("Amount cannot be negative");
|
|
|
|
Amount = amount;
|
|
Currency = currency?.ToUpperInvariant()
|
|
?? throw new ArgumentNullException(nameof(currency));
|
|
}
|
|
|
|
public Money Add(Money other)
|
|
{
|
|
if (Currency != other.Currency)
|
|
throw new InvalidOperationException("Cannot add different currencies");
|
|
|
|
return new Money(Amount + other.Amount, Currency);
|
|
}
|
|
|
|
protected override IEnumerable<object?> GetEqualityComponents()
|
|
{
|
|
yield return Amount;
|
|
yield return Currency;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Domain Events
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Domain event interface.
|
|
/// VI: Interface domain event.
|
|
/// </summary>
|
|
public interface IDomainEvent : INotification
|
|
{
|
|
Guid Id { get; }
|
|
DateTime OccurredOn { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Domain event when order is submitted.
|
|
/// VI: Domain event khi order được submit.
|
|
/// </summary>
|
|
public record OrderSubmittedDomainEvent : IDomainEvent
|
|
{
|
|
public Guid Id { get; } = Guid.NewGuid();
|
|
public DateTime OccurredOn { get; } = DateTime.UtcNow;
|
|
public Guid OrderId { get; init; }
|
|
public string UserId { get; init; }
|
|
public decimal TotalAmount { get; init; }
|
|
|
|
public OrderSubmittedDomainEvent(Guid orderId, string userId, decimal totalAmount)
|
|
{
|
|
OrderId = orderId;
|
|
UserId = userId;
|
|
TotalAmount = totalAmount;
|
|
}
|
|
}
|
|
```
|
|
|
|
## Common Mistakes / Lỗi Thường Gặp
|
|
|
|
### 1. Anemic Domain Model
|
|
|
|
```csharp
|
|
// ❌ BAD: Anemic model with no behavior
|
|
public class Order
|
|
{
|
|
public Guid Id { get; set; }
|
|
public string Status { get; set; }
|
|
public List<OrderItem> Items { get; set; }
|
|
}
|
|
|
|
// ✅ GOOD: Rich domain model with behavior
|
|
public class Order
|
|
{
|
|
private readonly List<OrderItem> _items = new();
|
|
public OrderStatus Status { get; private set; }
|
|
|
|
public void AddItem(Guid productId, int quantity, decimal price)
|
|
{
|
|
if (Status != OrderStatus.Draft)
|
|
throw new DomainException("Cannot modify non-draft order");
|
|
// ...
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2. Direct Property Modification
|
|
|
|
```csharp
|
|
// ❌ BAD: Direct modification bypasses rules
|
|
order.Status = OrderStatus.Submitted;
|
|
|
|
// ✅ GOOD: Use domain methods
|
|
order.Submit();
|
|
```
|
|
|
|
### 3. Logic in Application Layer
|
|
|
|
```csharp
|
|
// ❌ BAD: Business logic in handler
|
|
public async Task Handle(CreateOrderCommand cmd)
|
|
{
|
|
if (cmd.Items.Count == 0)
|
|
throw new Exception("Empty order");
|
|
// ...
|
|
}
|
|
|
|
// ✅ GOOD: Business logic in domain
|
|
public void Submit()
|
|
{
|
|
if (!_orderItems.Any())
|
|
throw new DomainException("Cannot submit empty order");
|
|
}
|
|
```
|
|
|
|
## Quick Reference / Tham Chiếu Nhanh
|
|
|
|
### Aggregate Design Guidelines
|
|
|
|
| Guideline | Description |
|
|
|-----------|-------------|
|
|
| Small aggregates | Keep aggregates focused |
|
|
| Reference by ID | 외부 aggregates chỉ tham chiếu qua ID |
|
|
| Eventual consistency | Between aggregates |
|
|
| Immediate consistency | Within aggregate |
|
|
|
|
### When to Use Each Pattern
|
|
|
|
| Pattern | Use When |
|
|
|---------|----------|
|
|
| Entity | Has identity, lifecycle |
|
|
| Value Object | No identity, immutable |
|
|
| Aggregate | Group of related entities |
|
|
| Domain Event | Side effects needed |
|
|
| Domain Service | Logic doesn't fit entity |
|
|
|
|
## Resources / Tài Nguyên
|
|
|
|
- [Detailed Examples](./references/REFERENCE.md) - Full code examples
|
|
- [Repository Pattern](../repository-pattern/SKILL.md) - Data access
|
|
- [CQRS MediatR](../cqrs-mediatr/SKILL.md) - Command handlers
|
|
- [Testing Patterns](../testing-patterns/SKILL.md) - Domain testing
|