Files
pos-system/microservices/.agent/skills/domain-driven-design/references/REFERENCE.md
Ho Ngoc Hai 76d75c753b Migrate
2026-05-23 18:37:02 +07:00

14 KiB

Domain-Driven Design - Detailed Reference

Detailed code examples cho DDD patterns trong GoodGo.

Table of Contents

  1. Entity Patterns
  2. Value Objects
  3. Aggregates
  4. Domain Events
  5. Domain Services
  6. Specifications

Entity Patterns

Complete Entity Base Class

/// <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)

/// <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

/// <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

/// <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

/// <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

/// <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

/// <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