ddd
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseDomain-Driven Design (DDD)
领域驱动设计(DDD)
Core Principles
核心原则
- 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.
- Value objects over primitives — Replace primitive obsession with value objects. ,
Money,EmailAddressare not strings — they carry validation, equality, and behavior. Use C# records for immutable value objects.OrderNumber - 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.
- 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.
- 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 internally — this is a DDD tactical pattern for aggregate boundaries, not a generic CRUD wrapper.
DbContext
- Aggregates(聚合)定义一致性边界 — 聚合是一组实体和值对象(value objects)的集群,在数据变更时被视为一个独立单元。聚合内的所有不变量都在单个事务中强制执行。跨聚合的一致性为最终一致性。
- 优先使用value objects(值对象)而非原始类型 — 用值对象替代“原始类型痴迷”问题。、
Money、EmailAddress并非简单字符串——它们包含验证逻辑、相等性判断和行为。使用C# record实现不可变值对象。OrderNumber - Domain events(领域事件)解耦副作用 — 当领域中发生重要事件(如OrderPlaced、PaymentReceived)时,触发领域事件。副作用(发送邮件、更新读模型、通知其他聚合)订阅这些事件。聚合只需专注自身规则。
- Aggregate root(聚合根)是唯一入口 — 外部代码只能通过根实体访问聚合。子实体永远不能被独立加载或修改。根实体负责强制执行整个聚合的所有不变量。
- Repositories(仓储)持久化聚合而非实体 — 每个聚合根对应一个仓储。仓储以单元形式加载和保存整个聚合。子实体无需对应仓储。基础设施实现内部使用——这是用于聚合边界的DDD战术模式,而非通用CRUD包装器。
DbContext
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 settersStrongly-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 neededcsharp
// 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 neededValue 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, recalculatescsharp
// 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, recalculatesDecision Guide
决策指南
| Scenario | Recommendation |
|---|---|
| When to use DDD | Complex domain with business rules that go beyond CRUD |
| When to use value objects | Any concept with validation rules or equality based on attributes, not identity |
| Aggregate size | Keep small — typically 1 root entity + 0-3 child entities. Load the whole aggregate every time |
| Domain events vs integration events | Domain events: within bounded context, same transaction. Integration events: cross-context, via message bus |
| Strongly-typed IDs | Always for aggregate root IDs that cross boundaries. Optional for child entity IDs |
| When NOT to use DDD | Simple CRUD, settings, audit logs, read models — use plain entities |
| Repository vs DbContext | Repository per aggregate root for complex aggregates; IAppDbContext for simpler queries |
| Domain services | Only 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 |
| 领域服务 | 仅当逻辑需要多个聚合或聚合不应知晓的外部数据时使用 |