ddd

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Domain-Driven Design (DDD)

领域驱动设计(DDD)

Core Principles

核心原则

  1. Aggregates define consistency boundaries — An aggregate is a cluster of entities and value objects treated as a single unit for data changes. All invariants within an aggregate are enforced in a single transaction. Cross-aggregate consistency is eventual.
  2. Value objects over primitives — Replace primitive obsession with value objects.
    Money
    ,
    EmailAddress
    ,
    OrderNumber
    are not strings — they carry validation, equality, and behavior. Use C# records for immutable value objects.
  3. Domain events decouple side effects — When something meaningful happens in the domain (OrderPlaced, PaymentReceived), raise a domain event. Side effects (send email, update read model, notify another aggregate) subscribe to these events. The aggregate stays focused on its own rules.
  4. Aggregate root is the sole entry point — External code accesses an aggregate only through its root entity. Child entities are never loaded or modified independently. The root enforces all invariants for the entire aggregate.
  5. Repositories persist aggregates, not entities — One repository per aggregate root. The repository loads and saves the entire aggregate as a unit. No repository for child entities. The Infrastructure implementation uses
    DbContext
    internally — this is a DDD tactical pattern for aggregate boundaries, not a generic CRUD wrapper.
  1. Aggregates(聚合)定义一致性边界 — 聚合是一组实体和值对象(value objects)的集群,在数据变更时被视为一个独立单元。聚合内的所有不变量都在单个事务中强制执行。跨聚合的一致性为最终一致性。
  2. 优先使用value objects(值对象)而非原始类型 — 用值对象替代“原始类型痴迷”问题。
    Money
    EmailAddress
    OrderNumber
    并非简单字符串——它们包含验证逻辑、相等性判断和行为。使用C# record实现不可变值对象。
  3. Domain events(领域事件)解耦副作用 — 当领域中发生重要事件(如OrderPlaced、PaymentReceived)时,触发领域事件。副作用(发送邮件、更新读模型、通知其他聚合)订阅这些事件。聚合只需专注自身规则。
  4. Aggregate root(聚合根)是唯一入口 — 外部代码只能通过根实体访问聚合。子实体永远不能被独立加载或修改。根实体负责强制执行整个聚合的所有不变量。
  5. Repositories(仓储)持久化聚合而非实体 — 每个聚合根对应一个仓储。仓储以单元形式加载和保存整个聚合。子实体无需对应仓储。基础设施实现内部使用
    DbContext
    ——这是用于聚合边界的DDD战术模式,而非通用CRUD包装器。

Patterns

模式

Aggregate Root

聚合根(Aggregate Root)

The aggregate root owns all access to its children and enforces invariants:
csharp
// Domain/Orders/Order.cs
public sealed class Order : AggregateRoot
{
    private readonly List<OrderLine> _lines = [];

    private Order() { } // EF Core

    public OrderNumber Number { get; private set; } = null!;
    public CustomerId CustomerId { get; private set; }
    public Money Total { get; private set; } = Money.Zero("USD");
    public OrderStatus Status { get; private set; }
    public DateTimeOffset PlacedAt { get; private set; }
    public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();

    public static Order Place(CustomerId customerId, OrderNumber number, DateTimeOffset now)
    {
        var order = new Order
        {
            Id = Guid.CreateVersion7(),
            CustomerId = customerId,
            Number = number,
            Status = OrderStatus.Placed,
            PlacedAt = now
        };

        order.RaiseDomainEvent(new OrderPlaced(order.Id, customerId, now));
        return order;
    }

    public Result AddLine(ProductId productId, int quantity, Money unitPrice)
    {
        if (Status is not OrderStatus.Placed)
            return Result.Failure("Cannot modify a confirmed or cancelled order");

        if (quantity <= 0)
            return Result.Failure("Quantity must be positive");

        var existing = _lines.FirstOrDefault(l => l.ProductId == productId);
        if (existing is not null)
        {
            existing.IncreaseQuantity(quantity);
        }
        else
        {
            _lines.Add(new OrderLine(productId, quantity, unitPrice));
        }

        RecalculateTotal();
        return Result.Success();
    }

    public Result Confirm()
    {
        if (Status is not OrderStatus.Placed)
            return Result.Failure("Only placed orders can be confirmed");

        if (_lines.Count == 0)
            return Result.Failure("Cannot confirm an order with no lines");

        Status = OrderStatus.Confirmed;
        RaiseDomainEvent(new OrderConfirmed(Id));
        return Result.Success();
    }

    private void RecalculateTotal()
    {
        Total = _lines.Aggregate(Money.Zero(Total.Currency), (sum, line) => sum + line.Subtotal);
    }
}
聚合根拥有对子实体的所有访问权限并强制执行不变量:
csharp
// Domain/Orders/Order.cs
public sealed class Order : AggregateRoot
{
    private readonly List<OrderLine> _lines = [];

    private Order() { } // EF Core

    public OrderNumber Number { get; private set; } = null!;
    public CustomerId CustomerId { get; private set; }
    public Money Total { get; private set; } = Money.Zero("USD");
    public OrderStatus Status { get; private set; }
    public DateTimeOffset PlacedAt { get; private set; }
    public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();

    public static Order Place(CustomerId customerId, OrderNumber number, DateTimeOffset now)
    {
        var order = new Order
        {
            Id = Guid.CreateVersion7(),
            CustomerId = customerId,
            Number = number,
            Status = OrderStatus.Placed,
            PlacedAt = now
        };

        order.RaiseDomainEvent(new OrderPlaced(order.Id, customerId, now));
        return order;
    }

    public Result AddLine(ProductId productId, int quantity, Money unitPrice)
    {
        if (Status is not OrderStatus.Placed)
            return Result.Failure("Cannot modify a confirmed or cancelled order");

        if (quantity <= 0)
            return Result.Failure("Quantity must be positive");

        var existing = _lines.FirstOrDefault(l => l.ProductId == productId);
        if (existing is not null)
        {
            existing.IncreaseQuantity(quantity);
        }
        else
        {
            _lines.Add(new OrderLine(productId, quantity, unitPrice));
        }

        RecalculateTotal();
        return Result.Success();
    }

    public Result Confirm()
    {
        if (Status is not OrderStatus.Placed)
            return Result.Failure("Only placed orders can be confirmed");

        if (_lines.Count == 0)
            return Result.Failure("Cannot confirm an order with no lines");

        Status = OrderStatus.Confirmed;
        RaiseDomainEvent(new OrderConfirmed(Id));
        return Result.Success();
    }

    private void RecalculateTotal()
    {
        Total = _lines.Aggregate(Money.Zero(Total.Currency), (sum, line) => sum + line.Subtotal);
    }
}

Value Objects as Records

基于Record的Value Objects(值对象)

Use C# records for immutable value objects with structural equality:
csharp
// Domain/Common/Money.cs
public sealed record Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(amount);
        ArgumentException.ThrowIfNullOrWhiteSpace(currency);

        Amount = amount;
        Currency = currency.ToUpperInvariant();
    }

    public static Money Zero(string currency) => new(0, currency);

    public static Money operator +(Money left, Money right)
    {
        if (left.Currency != right.Currency)
            throw new InvalidOperationException($"Cannot add {left.Currency} and {right.Currency}");
        return new Money(left.Amount + right.Amount, left.Currency);
    }
}

// Other value objects (EmailAddress, OrderNumber, etc.) follow the same pattern:
// sealed record, constructor validation, no public setters
使用C# record实现具有结构相等性的不可变值对象:
csharp
// Domain/Common/Money.cs
public sealed record Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(amount);
        ArgumentException.ThrowIfNullOrWhiteSpace(currency);

        Amount = amount;
        Currency = currency.ToUpperInvariant();
    }

    public static Money Zero(string currency) => new(0, currency);

    public static Money operator +(Money left, Money right)
    {
        if (left.Currency != right.Currency)
            throw new InvalidOperationException($"Cannot add {left.Currency} and {right.Currency}");
        return new Money(left.Amount + right.Amount, left.Currency);
    }
}

// Other value objects (EmailAddress, OrderNumber, etc.) follow the same pattern:
// sealed record, constructor validation, no public setters

Strongly-Typed IDs with EF Core Converters

结合EF Core转换器的强类型ID

Prevent mixing up GUIDs from different entities:
csharp
// Domain/Common/StronglyTypedId.cs
public readonly record struct CustomerId(Guid Value)
{
    public static CustomerId New() => new(Guid.CreateVersion7());
    public override string ToString() => Value.ToString();
}

public readonly record struct ProductId(Guid Value)
{
    public static ProductId New() => new(Guid.CreateVersion7());
}

public readonly record struct OrderNumber(string Value)
{
    public override string ToString() => Value;
}

// Infrastructure/Persistence/Configurations/OrderConfiguration.cs
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.HasKey(o => o.Id);

        builder.Property(o => o.CustomerId)
            .HasConversion(id => id.Value, value => new CustomerId(value));

        builder.Property(o => o.Number)
            .HasConversion(n => n.Value, value => new OrderNumber(value))
            .HasMaxLength(50);

        builder.ComplexProperty(o => o.Total, money =>
        {
            money.Property(m => m.Amount).HasColumnName("Total").HasPrecision(18, 2);
            money.Property(m => m.Currency).HasColumnName("Currency").HasMaxLength(3);
        });

        builder.HasMany(o => o.Lines).WithOne().HasForeignKey("OrderId");
        builder.Navigation(o => o.Lines).AutoInclude();
    }
}
避免混淆不同实体的GUID:
csharp
// Domain/Common/StronglyTypedId.cs
public readonly record struct CustomerId(Guid Value)
{
    public static CustomerId New() => new(Guid.CreateVersion7());
    public override string ToString() => Value.ToString();
}

public readonly record struct ProductId(Guid Value)
{
    public static ProductId New() => new(Guid.CreateVersion7());
}

public readonly record struct OrderNumber(string Value)
{
    public override string ToString() => Value;
}

// Infrastructure/Persistence/Configurations/OrderConfiguration.cs
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.HasKey(o => o.Id);

        builder.Property(o => o.CustomerId)
            .HasConversion(id => id.Value, value => new CustomerId(value));

        builder.Property(o => o.Number)
            .HasConversion(n => n.Value, value => new OrderNumber(value))
            .HasMaxLength(50);

        builder.ComplexProperty(o => o.Total, money =>
        {
            money.Property(m => m.Amount).HasColumnName("Total").HasPrecision(18, 2);
            money.Property(m => m.Currency).HasColumnName("Currency").HasMaxLength(3);
        });

        builder.HasMany(o => o.Lines).WithOne().HasForeignKey("OrderId");
        builder.Navigation(o => o.Lines).AutoInclude();
    }
}

Domain Event Dispatching

领域事件分发

Raise events in the aggregate, dispatch in SaveChangesAsync:
csharp
// Domain/Common/AggregateRoot.cs
public abstract class AggregateRoot : Entity
{
    private readonly List<IDomainEvent> _domainEvents = [];

    public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    protected void RaiseDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent);

    public void ClearDomainEvents() => _domainEvents.Clear();
}

// INotification comes from the MIT-licensed Mediator package (not MediatR —
// see the packages rule). Use a plain marker interface if you don't use a mediator.
public interface IDomainEvent : INotification
{
    DateTimeOffset OccurredAt { get; }
}

// Domain/Orders/Events/OrderPlaced.cs
public sealed record OrderPlaced(Guid OrderId, CustomerId CustomerId, DateTimeOffset PlacedAt) : IDomainEvent
{
    public DateTimeOffset OccurredAt => PlacedAt;
}

// Infrastructure/Persistence/AppDbContext.cs — publisher injected via primary constructor
public class AppDbContext(DbContextOptions<AppDbContext> options, IPublisher publisher)
    : DbContext(options)
{
    public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
    {
        var aggregates = ChangeTracker.Entries<AggregateRoot>()
            .Where(e => e.Entity.DomainEvents.Count > 0)
            .Select(e => e.Entity)
            .ToList();

        var events = aggregates.SelectMany(a => a.DomainEvents).ToList();

        var result = await base.SaveChangesAsync(ct);

        foreach (var @event in events)
            await publisher.Publish(@event, ct);

        foreach (var aggregate in aggregates)
            aggregate.ClearDomainEvents();

        return result;
    }
}
在聚合中触发事件,在SaveChangesAsync中分发:
csharp
// Domain/Common/AggregateRoot.cs
public abstract class AggregateRoot : Entity
{
    private readonly List<IDomainEvent> _domainEvents = [];

    public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    protected void RaiseDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent);

    public void ClearDomainEvents() => _domainEvents.Clear();
}

// INotification comes from the MIT-licensed Mediator package (not MediatR —
// see the packages rule). Use a plain marker interface if you don't use a mediator.
public interface IDomainEvent : INotification
{
    DateTimeOffset OccurredAt { get; }
}

// Domain/Orders/Events/OrderPlaced.cs
public sealed record OrderPlaced(Guid OrderId, CustomerId CustomerId, DateTimeOffset PlacedAt) : IDomainEvent
{
    public DateTimeOffset OccurredAt => PlacedAt;
}

// Infrastructure/Persistence/AppDbContext.cs — publisher injected via primary constructor
public class AppDbContext(DbContextOptions<AppDbContext> options, IPublisher publisher)
    : DbContext(options)
{
    public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
    {
        var aggregates = ChangeTracker.Entries<AggregateRoot>()
            .Where(e => e.Entity.DomainEvents.Count > 0)
            .Select(e => e.Entity)
            .ToList();

        var events = aggregates.SelectMany(a => a.DomainEvents).ToList();

        var result = await base.SaveChangesAsync(ct);

        foreach (var @event in events)
            await publisher.Publish(@event, ct);

        foreach (var aggregate in aggregates)
            aggregate.ClearDomainEvents();

        return result;
    }
}

Domain Services

领域服务(Domain Services)

For logic that does not belong to a single aggregate:
csharp
// Domain/Orders/Services/PricingService.cs
// Coordinates logic across aggregates — takes domain interfaces, returns value objects
public sealed class PricingService(IDiscountPolicy discountPolicy)
{
    public Money CalculatePrice(ProductId productId, int quantity, Money unitPrice, CustomerId customerId)
    {
        var subtotal = new Money(unitPrice.Amount * quantity, unitPrice.Currency);
        var discount = discountPolicy.GetDiscount(customerId, productId, quantity);
        return new Money(subtotal.Amount * (1 - discount), subtotal.Currency);
    }
}
适用于不属于单个聚合的逻辑:
csharp
// Domain/Orders/Services/PricingService.cs
// Coordinates logic across aggregates — takes domain interfaces, returns value objects
public sealed class PricingService(IDiscountPolicy discountPolicy)
{
    public Money CalculatePrice(ProductId productId, int quantity, Money unitPrice, CustomerId customerId)
    {
        var subtotal = new Money(unitPrice.Amount * quantity, unitPrice.Currency);
        var discount = discountPolicy.GetDiscount(customerId, productId, quantity);
        return new Money(subtotal.Amount * (1 - discount), subtotal.Currency);
    }
}

Anti-patterns

反模式

Oversized Aggregates

过大的聚合

csharp
// BAD — Customer aggregate owns everything the customer touches
public class Customer : AggregateRoot
{
    public List<Order> Orders { get; } = [];        // should be separate aggregate
    public List<Payment> Payments { get; } = [];     // should be separate aggregate
    public List<Address> Addresses { get; } = [];    // might be OK as child
    public ShoppingCart Cart { get; set; }            // should be separate aggregate
}

// GOOD — small, focused aggregates linked by ID
public class Customer : AggregateRoot
{
    public CustomerName Name { get; private set; }
    public EmailAddress Email { get; private set; }
    // Orders, Payments, Cart are separate aggregates referencing CustomerId
}
csharp
// BAD — Customer aggregate owns everything the customer touches
public class Customer : AggregateRoot
{
    public List<Order> Orders { get; } = [];        // should be separate aggregate
    public List<Payment> Payments { get; } = [];     // should be separate aggregate
    public List<Address> Addresses { get; } = [];    // might be OK as child
    public ShoppingCart Cart { get; set; }            // should be separate aggregate
}

// GOOD — small, focused aggregates linked by ID
public class Customer : AggregateRoot
{
    public CustomerName Name { get; private set; }
    public EmailAddress Email { get; private set; }
    // Orders, Payments, Cart are separate aggregates referencing CustomerId
}

Domain Events for Intra-Aggregate Logic

聚合内部逻辑使用领域事件

csharp
// BAD — using events for logic within the same aggregate
order.RaiseDomainEvent(new OrderLineAdded(line));
// Then a handler recalculates the total... but you're in the same aggregate!

// GOOD — just call the method directly within the aggregate
_lines.Add(line);
RecalculateTotal();  // private method, no event needed
csharp
// BAD — using events for logic within the same aggregate
order.RaiseDomainEvent(new OrderLineAdded(line));
// Then a handler recalculates the total... but you're in the same aggregate!

// GOOD — just call the method directly within the aggregate
_lines.Add(line);
RecalculateTotal();  // private method, no event needed

Value Objects with Identity

带标识的Value Objects(值对象)

csharp
// BAD — value object with an Id (it's an entity then!)
public record Address
{
    public Guid Id { get; init; }  // value objects don't have identity
    public string Street { get; init; }
}

// GOOD — value objects are defined by their attributes, not an Id
public record Address(string Street, string City, string PostalCode, string Country);
csharp
// BAD — value object with an Id (it's an entity then!)
public record Address
{
    public Guid Id { get; init; }  // value objects don't have identity
    public string Street { get; init; }
}

// GOOD — value objects are defined by their attributes, not an Id
public record Address(string Street, string City, string PostalCode, string Country);

Anemic Aggregates

贫血聚合

csharp
// BAD — aggregate is just a data bag, service does all the work
public class Order : AggregateRoot
{
    public OrderStatus Status { get; set; }  // public setter!
    public List<OrderLine> Lines { get; set; } = [];
}

// Service directly manipulates order state
order.Status = OrderStatus.Confirmed;  // no invariant check!
order.Lines.Add(newLine);              // no validation!

// GOOD — aggregate encapsulates rules (see Aggregate Root pattern above)
order.Confirm();  // validates status, raises event
order.AddLine(productId, quantity, unitPrice);  // validates, recalculates
csharp
// BAD — aggregate is just a data bag, service does all the work
public class Order : AggregateRoot
{
    public OrderStatus Status { get; set; }  // public setter!
    public List<OrderLine> Lines { get; set; } = [];
}

// Service directly manipulates order state
order.Status = OrderStatus.Confirmed;  // no invariant check!
order.Lines.Add(newLine);              // no validation!

// GOOD — aggregate encapsulates rules (see Aggregate Root pattern above)
order.Confirm();  // validates status, raises event
order.AddLine(productId, quantity, unitPrice);  // validates, recalculates

Decision Guide

决策指南

ScenarioRecommendation
When to use DDDComplex domain with business rules that go beyond CRUD
When to use value objectsAny concept with validation rules or equality based on attributes, not identity
Aggregate sizeKeep small — typically 1 root entity + 0-3 child entities. Load the whole aggregate every time
Domain events vs integration eventsDomain events: within bounded context, same transaction. Integration events: cross-context, via message bus
Strongly-typed IDsAlways for aggregate root IDs that cross boundaries. Optional for child entity IDs
When NOT to use DDDSimple CRUD, settings, audit logs, read models — use plain entities
Repository vs DbContextRepository per aggregate root for complex aggregates; IAppDbContext for simpler queries
Domain servicesOnly when logic requires multiple aggregates or external data the aggregate should not know about
场景建议
何时使用DDD业务规则复杂、超出CRUD范畴的领域
何时使用值对象任何具有验证规则或基于属性而非标识判断相等性的概念
聚合大小保持小巧——通常为1个根实体 + 0-3个子实体。每次加载完整聚合
领域事件 vs 集成事件领域事件:限界上下文内,同一事务;集成事件:跨上下文,通过消息总线
强类型ID跨边界的聚合根ID必须使用;子实体ID可选
何时不使用DDD简单CRUD、配置、审计日志、读模型——使用普通实体即可
Repository vs DbContext复杂聚合使用每个聚合根对应一个仓储;简单查询使用IAppDbContext
领域服务仅当逻辑需要多个聚合或聚合不应知晓的外部数据时使用