Migrate
This commit is contained in:
@@ -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/)
|
||||
Reference in New Issue
Block a user