This commit is contained in:
Ho Ngoc Hai
2026-05-23 18:37:02 +07:00
parent f15d91ee29
commit 76d75c753b
3993 changed files with 403 additions and 0 deletions

View File

@@ -0,0 +1,530 @@
# 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/)