531 lines
14 KiB
Markdown
531 lines
14 KiB
Markdown
# Domain-Driven Design - Detailed Reference
|
|
|
|
Detailed code examples cho DDD patterns trong GoodGo.
|
|
|
|
## Table of Contents
|
|
|
|
1. [Entity Patterns](#entity-patterns)
|
|
2. [Value Objects](#value-objects)
|
|
3. [Aggregates](#aggregates)
|
|
4. [Domain Events](#domain-events)
|
|
5. [Domain Services](#domain-services)
|
|
6. [Specifications](#specifications)
|
|
|
|
---
|
|
|
|
## Entity Patterns
|
|
|
|
### Complete Entity Base Class
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Base Entity class with domain events support.
|
|
/// VI: Base Entity class với hỗ trợ domain events.
|
|
/// </summary>
|
|
public abstract class Entity : IEquatable<Entity>
|
|
{
|
|
private readonly List<IDomainEvent> _domainEvents = new();
|
|
private int? _requestedHashCode;
|
|
|
|
public virtual Guid Id { get; protected set; }
|
|
|
|
public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
|
|
|
|
protected void AddDomainEvent(IDomainEvent domainEvent)
|
|
{
|
|
_domainEvents.Add(domainEvent);
|
|
}
|
|
|
|
public void ClearDomainEvents() => _domainEvents.Clear();
|
|
|
|
public bool IsTransient() => Id == default;
|
|
|
|
public bool Equals(Entity? other)
|
|
{
|
|
if (other is null) 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 bool Equals(object? obj) => Equals(obj as Entity);
|
|
|
|
public override int GetHashCode()
|
|
{
|
|
if (IsTransient()) return base.GetHashCode();
|
|
_requestedHashCode ??= Id.GetHashCode() ^ 31;
|
|
return _requestedHashCode.Value;
|
|
}
|
|
|
|
public static bool operator ==(Entity? left, Entity? right) => Equals(left, right);
|
|
public static bool operator !=(Entity? left, Entity? right) => !Equals(left, right);
|
|
}
|
|
```
|
|
|
|
### Child Entity (OrderItem)
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: OrderItem entity belonging to Order aggregate.
|
|
/// VI: Entity OrderItem thuộc Order aggregate.
|
|
/// </summary>
|
|
public class OrderItem : Entity
|
|
{
|
|
public Guid ProductId { get; private set; }
|
|
public string ProductName { get; private set; }
|
|
public int Quantity { get; private set; }
|
|
public decimal UnitPrice { get; private set; }
|
|
public decimal TotalPrice => Quantity * UnitPrice;
|
|
|
|
// EN: Required by EF Core
|
|
private OrderItem() { }
|
|
|
|
internal OrderItem(Guid productId, string productName, int quantity, decimal unitPrice)
|
|
{
|
|
Id = Guid.NewGuid();
|
|
ProductId = productId;
|
|
ProductName = productName ?? throw new ArgumentNullException(nameof(productName));
|
|
SetQuantity(quantity);
|
|
UnitPrice = unitPrice >= 0 ? unitPrice
|
|
: throw new ArgumentException("Price cannot be negative");
|
|
}
|
|
|
|
internal void SetQuantity(int quantity)
|
|
{
|
|
if (quantity <= 0)
|
|
throw new DomainException("Quantity must be positive");
|
|
|
|
Quantity = quantity;
|
|
}
|
|
|
|
internal void IncreaseQuantity(int amount)
|
|
{
|
|
if (amount <= 0)
|
|
throw new ArgumentException("Amount must be positive");
|
|
|
|
Quantity += amount;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Value Objects
|
|
|
|
### Value Object Base
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Base class for value objects with equality support.
|
|
/// VI: Base class cho value objects với hỗ trợ equality.
|
|
/// </summary>
|
|
public abstract class ValueObject : IEquatable<ValueObject>
|
|
{
|
|
protected abstract IEnumerable<object?> GetEqualityComponents();
|
|
|
|
public bool Equals(ValueObject? other)
|
|
{
|
|
if (other is null) return false;
|
|
if (ReferenceEquals(this, other)) return true;
|
|
if (GetType() != other.GetType()) return false;
|
|
return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
|
|
}
|
|
|
|
public override bool Equals(object? obj) => Equals(obj as ValueObject);
|
|
|
|
public override int GetHashCode()
|
|
{
|
|
return GetEqualityComponents()
|
|
.Aggregate(0, (hash, component) =>
|
|
HashCode.Combine(hash, component?.GetHashCode() ?? 0));
|
|
}
|
|
|
|
public static bool operator ==(ValueObject? left, ValueObject? right)
|
|
=> Equals(left, right);
|
|
|
|
public static bool operator !=(ValueObject? left, ValueObject? right)
|
|
=> !Equals(left, right);
|
|
}
|
|
```
|
|
|
|
### Complex Value Objects
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Email value object with validation.
|
|
/// VI: Value object email với validation.
|
|
/// </summary>
|
|
public sealed class Email : ValueObject
|
|
{
|
|
public string Value { get; }
|
|
|
|
private Email(string value) => Value = value;
|
|
|
|
public static Email Create(string email)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(email))
|
|
throw new DomainException("Email cannot be empty");
|
|
|
|
if (!IsValidEmail(email))
|
|
throw new DomainException("Invalid email format");
|
|
|
|
return new Email(email.ToLowerInvariant());
|
|
}
|
|
|
|
private static bool IsValidEmail(string email)
|
|
{
|
|
try
|
|
{
|
|
var addr = new System.Net.Mail.MailAddress(email);
|
|
return addr.Address == email;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
protected override IEnumerable<object?> GetEqualityComponents()
|
|
{
|
|
yield return Value;
|
|
}
|
|
|
|
public override string ToString() => Value;
|
|
|
|
public static implicit operator string(Email email) => email.Value;
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: DateRange value object.
|
|
/// VI: Value object khoảng thời gian.
|
|
/// </summary>
|
|
public sealed class DateRange : ValueObject
|
|
{
|
|
public DateTime Start { get; }
|
|
public DateTime End { get; }
|
|
public TimeSpan Duration => End - Start;
|
|
|
|
private DateRange(DateTime start, DateTime end)
|
|
{
|
|
Start = start;
|
|
End = end;
|
|
}
|
|
|
|
public static DateRange Create(DateTime start, DateTime end)
|
|
{
|
|
if (end < start)
|
|
throw new DomainException("End date must be after start date");
|
|
|
|
return new DateRange(start, end);
|
|
}
|
|
|
|
public bool Contains(DateTime date) => date >= Start && date <= End;
|
|
|
|
public bool Overlaps(DateRange other)
|
|
=> Start < other.End && End > other.Start;
|
|
|
|
protected override IEnumerable<object?> GetEqualityComponents()
|
|
{
|
|
yield return Start;
|
|
yield return End;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Money value object with currency operations.
|
|
/// VI: Value object tiền tệ với các phép toán.
|
|
/// </summary>
|
|
public sealed class Money : ValueObject
|
|
{
|
|
public decimal Amount { get; }
|
|
public Currency Currency { get; }
|
|
|
|
private Money(decimal amount, Currency currency)
|
|
{
|
|
Amount = amount;
|
|
Currency = currency;
|
|
}
|
|
|
|
public static Money Create(decimal amount, Currency currency)
|
|
{
|
|
if (amount < 0)
|
|
throw new DomainException("Amount cannot be negative");
|
|
|
|
return new Money(amount, currency);
|
|
}
|
|
|
|
public static Money Zero(Currency currency) => new(0, currency);
|
|
|
|
public Money Add(Money other)
|
|
{
|
|
EnsureSameCurrency(other);
|
|
return new Money(Amount + other.Amount, Currency);
|
|
}
|
|
|
|
public Money Subtract(Money other)
|
|
{
|
|
EnsureSameCurrency(other);
|
|
var result = Amount - other.Amount;
|
|
if (result < 0)
|
|
throw new DomainException("Insufficient funds");
|
|
return new Money(result, Currency);
|
|
}
|
|
|
|
public Money Multiply(decimal factor)
|
|
{
|
|
if (factor < 0)
|
|
throw new ArgumentException("Factor cannot be negative");
|
|
return new Money(Amount * factor, Currency);
|
|
}
|
|
|
|
private void EnsureSameCurrency(Money other)
|
|
{
|
|
if (Currency != other.Currency)
|
|
throw new DomainException("Cannot perform operation on different currencies");
|
|
}
|
|
|
|
protected override IEnumerable<object?> GetEqualityComponents()
|
|
{
|
|
yield return Amount;
|
|
yield return Currency;
|
|
}
|
|
|
|
public override string ToString() => $"{Amount:N2} {Currency}";
|
|
}
|
|
|
|
public enum Currency { VND, USD, EUR }
|
|
```
|
|
|
|
---
|
|
|
|
## Aggregates
|
|
|
|
### Complete Aggregate Example
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Order aggregate with full business logic.
|
|
/// VI: Order aggregate với business logic đầy đủ.
|
|
/// </summary>
|
|
public class Order : Entity, IAggregateRoot
|
|
{
|
|
private readonly List<OrderItem> _items = new();
|
|
|
|
public string UserId { get; private set; } = default!;
|
|
public Address ShippingAddress { get; private set; } = default!;
|
|
public Address? BillingAddress { get; private set; }
|
|
public OrderStatus Status { get; private set; }
|
|
public Money TotalAmount { get; private set; } = Money.Zero(Currency.VND);
|
|
public string? CancellationReason { get; private set; }
|
|
public DateTime CreatedAt { get; private set; }
|
|
public DateTime? SubmittedAt { get; private set; }
|
|
public DateTime? ShippedAt { get; private set; }
|
|
public DateTime? DeliveredAt { get; private set; }
|
|
|
|
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
|
|
|
|
private Order() { } // EF Core
|
|
|
|
public Order(string userId, Address shippingAddress, Address? billingAddress = null)
|
|
{
|
|
Id = Guid.NewGuid();
|
|
UserId = userId ?? throw new ArgumentNullException(nameof(userId));
|
|
ShippingAddress = shippingAddress ?? throw new ArgumentNullException(nameof(shippingAddress));
|
|
BillingAddress = billingAddress;
|
|
Status = OrderStatus.Draft;
|
|
CreatedAt = DateTime.UtcNow;
|
|
|
|
AddDomainEvent(new OrderCreatedDomainEvent(Id, userId));
|
|
}
|
|
|
|
// EN: Command methods / VI: Các method command
|
|
public void AddItem(Guid productId, string productName, int quantity, decimal unitPrice)
|
|
{
|
|
EnsureOrderIsDraft();
|
|
|
|
var existingItem = _items.FirstOrDefault(i => i.ProductId == productId);
|
|
if (existingItem != null)
|
|
{
|
|
existingItem.IncreaseQuantity(quantity);
|
|
}
|
|
else
|
|
{
|
|
var item = new OrderItem(productId, productName, quantity, unitPrice);
|
|
_items.Add(item);
|
|
}
|
|
|
|
RecalculateTotal();
|
|
AddDomainEvent(new OrderItemAddedDomainEvent(Id, productId, quantity));
|
|
}
|
|
|
|
public void UpdateItemQuantity(Guid productId, int newQuantity)
|
|
{
|
|
EnsureOrderIsDraft();
|
|
|
|
var item = _items.FirstOrDefault(i => i.ProductId == productId)
|
|
?? throw new DomainException($"Item {productId} not found in order");
|
|
|
|
if (newQuantity <= 0)
|
|
{
|
|
_items.Remove(item);
|
|
}
|
|
else
|
|
{
|
|
item.SetQuantity(newQuantity);
|
|
}
|
|
|
|
RecalculateTotal();
|
|
}
|
|
|
|
public void UpdateShippingAddress(Address newAddress)
|
|
{
|
|
if (Status == OrderStatus.Shipped || Status == OrderStatus.Delivered)
|
|
throw new DomainException("Cannot change address for shipped order");
|
|
|
|
ShippingAddress = newAddress ?? throw new ArgumentNullException(nameof(newAddress));
|
|
}
|
|
|
|
public void Submit()
|
|
{
|
|
EnsureOrderIsDraft();
|
|
|
|
if (!_items.Any())
|
|
throw new DomainException("Cannot submit empty order");
|
|
|
|
Status = OrderStatus.Submitted;
|
|
SubmittedAt = DateTime.UtcNow;
|
|
|
|
AddDomainEvent(new OrderSubmittedDomainEvent(Id, UserId, TotalAmount.Amount));
|
|
}
|
|
|
|
public void ConfirmPayment()
|
|
{
|
|
if (Status != OrderStatus.Submitted)
|
|
throw new DomainException($"Cannot confirm payment for order in {Status} status");
|
|
|
|
Status = OrderStatus.Paid;
|
|
AddDomainEvent(new OrderPaidDomainEvent(Id, TotalAmount.Amount));
|
|
}
|
|
|
|
public void Ship(string trackingNumber)
|
|
{
|
|
if (Status != OrderStatus.Paid)
|
|
throw new DomainException("Order must be paid before shipping");
|
|
|
|
Status = OrderStatus.Shipped;
|
|
ShippedAt = DateTime.UtcNow;
|
|
|
|
AddDomainEvent(new OrderShippedDomainEvent(Id, trackingNumber));
|
|
}
|
|
|
|
public void Cancel(string reason)
|
|
{
|
|
if (Status == OrderStatus.Shipped || Status == OrderStatus.Delivered)
|
|
throw new DomainException("Cannot cancel shipped order");
|
|
|
|
Status = OrderStatus.Cancelled;
|
|
CancellationReason = reason;
|
|
|
|
AddDomainEvent(new OrderCancelledDomainEvent(Id, reason));
|
|
}
|
|
|
|
// EN: Private helpers / VI: Helpers private
|
|
private void EnsureOrderIsDraft()
|
|
{
|
|
if (Status != OrderStatus.Draft)
|
|
throw new DomainException("Order is not in draft status");
|
|
}
|
|
|
|
private void RecalculateTotal()
|
|
{
|
|
var total = _items.Sum(i => i.TotalPrice);
|
|
TotalAmount = Money.Create(total, Currency.VND);
|
|
}
|
|
}
|
|
|
|
public enum OrderStatus
|
|
{
|
|
Draft,
|
|
Submitted,
|
|
Paid,
|
|
Shipped,
|
|
Delivered,
|
|
Cancelled
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Domain Events
|
|
|
|
### Domain Event Definitions
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Domain events for Order aggregate.
|
|
/// VI: Domain events cho Order aggregate.
|
|
/// </summary>
|
|
public record OrderCreatedDomainEvent(Guid OrderId, string UserId) : IDomainEvent
|
|
{
|
|
public Guid Id { get; } = Guid.NewGuid();
|
|
public DateTime OccurredOn { get; } = DateTime.UtcNow;
|
|
}
|
|
|
|
public record OrderItemAddedDomainEvent(Guid OrderId, Guid ProductId, int Quantity) : IDomainEvent
|
|
{
|
|
public Guid Id { get; } = Guid.NewGuid();
|
|
public DateTime OccurredOn { get; } = DateTime.UtcNow;
|
|
}
|
|
|
|
public record OrderSubmittedDomainEvent(Guid OrderId, string UserId, decimal TotalAmount) : IDomainEvent
|
|
{
|
|
public Guid Id { get; } = Guid.NewGuid();
|
|
public DateTime OccurredOn { get; } = DateTime.UtcNow;
|
|
}
|
|
```
|
|
|
|
### Domain Event Dispatcher
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Dispatch domain events via MediatR.
|
|
/// VI: Dispatch domain events qua MediatR.
|
|
/// </summary>
|
|
public class DomainEventDispatcher : IDomainEventDispatcher
|
|
{
|
|
private readonly IMediator _mediator;
|
|
private readonly ILogger<DomainEventDispatcher> _logger;
|
|
|
|
public async Task DispatchEventsAsync(IEnumerable<Entity> entities, CancellationToken ct = default)
|
|
{
|
|
var domainEvents = entities
|
|
.SelectMany(e => e.DomainEvents)
|
|
.ToList();
|
|
|
|
foreach (var domainEvent in domainEvents)
|
|
{
|
|
_logger.LogDebug(
|
|
"Dispatching domain event {EventType}: {EventId}",
|
|
domainEvent.GetType().Name,
|
|
domainEvent.Id);
|
|
|
|
await _mediator.Publish(domainEvent, ct);
|
|
}
|
|
|
|
foreach (var entity in entities)
|
|
{
|
|
entity.ClearDomainEvents();
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Resources / Tài Nguyên
|
|
|
|
- [Domain-Driven Design - Eric Evans](https://www.domainlanguage.com/)
|
|
- [Implementing DDD - Vaughn Vernon](https://www.informit.com/store/implementing-domain-driven-design-9780321834577)
|
|
- [Microsoft DDD Guidance](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/)
|