关于 dotnet-domain-modeling
面向 C# 开发者的领域驱动设计战术模式指南,聚焦与持久化技术无关的纯领域模型。内容涵盖聚合、值对象、领域事件及仓储契约,并讨论丰富模型与贫血模型的取舍。
主要功能
- 01设计聚合根与实体
- 02使用值对象表达领域概念
- 03定义及分发领域事件
- 04区分领域事件与集成事件
- 05设计领域服务和仓储契约
- 06比较丰富与贫血领域模型
适用场景
- 01为复杂业务规则划分聚合边界
- 02在领域模型中表达约束与值语义
- 03规划领域事件及副作用处理
- 04设计隔离持久化实现的仓储接口
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-domain-modeling安装后在 Claude Code 中输入/dotnet-domain-modeling确认已加载。
详细介绍
dotnet-domain-modeling
C# 中的领域驱动设计战术模式。涵盖聚合根、实体、值对象、领域事件、集成事件、领域服务、仓储契约设计,以及充血领域模型与贫血领域模型之间的区别。这些模式适用于领域层本身——用于封装业务规则的纯 C# 模型——且独立于任何持久化技术。
不在范围内: EF Core 配置和聚合持久化映射——请参阅 [skill:dotnet-efcore-architecture]。EF Core 战术用法(DbContext 生命周期、迁移、拦截器)——请参阅 [skill:dotnet-efcore-patterns]。API 边界的输入验证——请参阅 [skill:dotnet-validation-patterns]。在 EF Core、Dapper 和 ADO.NET 之间进行选择——请参阅 [skill:dotnet-data-access-strategy]。垂直切片架构和请求管道模式——请参阅 [skill:dotnet-architecture-patterns]。消息传递基础设施和 Saga 编排——请参阅 [skill:dotnet-messaging-patterns]。
交叉引用:[skill:dotnet-efcore-architecture]:聚合持久化以及使用 EF Core 实现仓储;[skill:dotnet-efcore-patterns]:DbContext 配置和迁移;[skill:dotnet-architecture-patterns]:垂直切片和请求管道设计;[skill:dotnet-validation-patterns]:输入验证模式;[skill:dotnet-messaging-patterns]:集成事件基础设施。
聚合根与实体
聚合是一组作为数据变更单元处理的领域对象。聚合根是入口点——对聚合的所有修改都必须通过它进行。
实体基类
实体具有在状态变更后仍能保持不变的标识。使用基类统一标识和相等性处理:
public abstract class Entity<TId> : IEquatable<Entity<TId>>
where TId : notnull
{
// default! required for ORM hydration; Id is set immediately after construction
public TId Id { get; protected set; } = default!;
protected Entity() { } // Required for ORM hydration
protected Entity(TId id) => Id = id;
public override bool Equals(object? obj) =>
obj is Entity<TId> other && Equals(other);
public bool Equals(Entity<TId>? other) =>
other is not null
&& GetType() == other.GetType()
&& EqualityComparer<TId>.Default.Equals(Id, other.Id);
public override int GetHashCode() =>
EqualityComparer<TId>.Default.GetHashCode(Id);
public static bool operator ==(Entity<TId>? left, Entity<TId>? right) =>
Equals(left, right);
public static bool operator !=(Entity<TId>? left, Entity<TId>? right) =>
!Equals(left, right);
}
聚合根基类
聚合根扩展 Entity,并收集领域事件:
public abstract class AggregateRoot<TId> : Entity<TId>
where TId : notnull
{
private readonly List<IDomainEvent> _domainEvents = [];
public IReadOnlyList<IDomainEvent> DomainEvents =>
_domainEvents.AsReadOnly();
protected AggregateRoot() { }
protected AggregateRoot(TId id) : base(id) { }
protected void RaiseDomainEvent(IDomainEvent domainEvent) =>
_domainEvents.Add(domainEvent);
public void ClearDomainEvents() => _domainEvents.Clear();
}
具体聚合示例
public sealed class Order : AggregateRoot<Guid>
{
public CustomerId CustomerId { get; private set; } = default!;
public OrderStatus Status { get; private set; }
public Money Total { get; private set; } = Money.Zero("USD");
private readonly List<OrderLine> _lines = [];
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
private Order() { } // ORM constructor
public static Order Create(CustomerId customerId)
{
var order = new Order(Guid.NewGuid())
{
CustomerId = customerId,
Status = OrderStatus.Draft
};
order.RaiseDomainEvent(new OrderCreated(order.Id, customerId));
return order;
}
public void AddLine(ProductId productId, int quantity, Money unitPrice)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Cannot modify a non-draft order.");
if (quantity <= 0)
throw new DomainException("Quantity must be positive.");
var line = new OrderLine(productId, quantity, unitPrice);
_lines.Add(line);
RecalculateTotal();
}
public void Submit()
{
if (Status != OrderStatus.Draft)
throw new DomainException("Only draft orders can be submitted.");
if (_lines.Count == 0)
throw new DomainException("Cannot submit an empty order.");
Status = OrderStatus.Submitted;
RaiseDomainEvent(new OrderSubmitted(Id, Total));
}
private void RecalculateTotal() =>
Total = _lines.Aggregate(
Money.Zero(Total.Currency),
(sum, line) => sum.Add(line.LineTotal));
}
聚合设计规则
| 规则 | 原因 |
|---|---|
| 所有变更都通过聚合根进行 | 在一个地方强制执行不变量 |
| 仅通过 ID 引用其他聚合 | 防止聚合间耦合;使用 CustomerId,而不是 Customer |
| 保持聚合精简 | 大型聚合会导致锁竞争和加载缓慢 |
| 每个事务只处理一个聚合 | 跨聚合变更使用领域事件和最终一致性 |
将集合公开为 IReadOnlyList<T> | 防止外部代码绕过根方法修改子对象 |
有关这些规则对 EF Core 持久化的影响(导航属性、拥有类型、级联行为),请参阅 [skill:dotnet-efcore-architecture]。
值对象
值对象没有标识——它们由属性值定义。属性相同的两个值对象相等。在 C# 中,record 和 record struct 可自然地提供值语义。
基于 Record 的值对象
// Simple value object -- wraps a primitive to enforce constraints
public sealed record CustomerId
{
public string Value { get; }
public CustomerId(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new DomainException("Customer ID cannot be empty.");
Value = value;
}
public override string ToString() => Value;
}
// Composite value object -- multiple properties with validation
public sealed record Address
{
public string Street { get; }
public string City { get; }
public string State { get; }
public string PostalCode { get; }
public string Country { get; }
public Address(string street, string city, string state,
string postalCode, string country)
{
if (string.IsNullOrWhiteSpace(street))
throw new DomainException("Street is required.");
if (string.IsNullOrWhiteSpace(city))
throw new DomainException("City is required.");
if (string.IsNullOrWhiteSpace(postalCode))
throw new DomainException("Postal code is required.");
Street = street;
City = city;
State = state;
PostalCode = postalCode;
Country = country;
}
}
Money 值对象
Money 是具有行为的多字段值对象的典型示例:
public sealed record Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (string.IsNullOrWhiteSpace(currency))
throw new DomainException("Currency is required.");
Amount = amount;
Currency = currency.ToUpperInvariant();
}
public static Money Zero(string currency) => new(0m, currency);
public Money Add(Money other)
{
EnsureSameCurrency(other);
return new Money(Amount + other.Amount, Currency);
}
public Money Subtract(Money other)
{
EnsureSameCurrency(other);
return new Money(Amount - other.Amount, Currency);
}
public Money Multiply(int quantity) =>
new(Amount * quantity, Currency);
public Money Multiply(decimal factor) =>
new(Amount * factor, Currency);
private void EnsureSameCurrency(Money other)
{
if (Currency != other.Currency)
throw new DomainException(
$"Cannot operate on {Currency} and {other.Currency}.");
}
public override string ToString() => $"{Amount:F2} {Currency}";
}
值对象的 EF Core 映射
使用拥有类型或值转换映射值对象(实现见 [skill:dotnet-efcore-architecture]):
// Owned type -- maps to columns in the parent table
builder.OwnsOne(o => o.Total, money =>
{
money.Property(m => m.Amount).HasColumnName("TotalAmount");
money.Property(m => m.Currency).HasColumnName("TotalCurrency")
.HasMaxLength(3);
});
// Value conversion -- single-property value objects
builder.Property(o => o.CustomerId)
.HasConversion(
id => id.Value,
value => new CustomerId(value))
.HasMaxLength(50);
何时使用值对象
| 使用值对象 | 使用基本类型 |
|---|---|
| 带约束的领域概念(电子邮件、金额、数量) | 没有领域规则的基础设施 ID(关联 ID、跟踪 ID) |
| 构成一个整体的多个属性(地址、日期范围) | 无需验证的单个值 |
| 需要避免领域方法中的基本类型偏执 | API 边界处的简单 DTO 字段 |
领域事件
领域事件表示领域中发生的有意义的事情。它们能减少聚合间的耦合,并触发副作用(发送电子邮件、更新读取模型、发布集成事件)。
事件契约
// Marker interface for all domain events
public interface IDomainEvent
{
Guid EventId { get; }
DateTimeOffset OccurredAt { get; }
}
// Base record for convenience
public abstract record DomainEventBase : IDomainEvent
{
public Guid EventId { get; } = Guid.NewGuid();
public DateTimeOffset OccurredAt { get; } = DateTimeOffset.UtcNow;
}
// Concrete events
public sealed record OrderCreated(
Guid OrderId, CustomerId CustomerId) : DomainEventBase;
public sealed record OrderSubmitted(
Guid OrderId, Money Total) : DomainEventBase;
public sealed record OrderCancelled(
Guid OrderId, string Reason) : DomainEventBase;
分发领域事件
在 SaveChangesAsync 成功后分发事件,确保副作用执行前聚合状态已持久化:
public sealed class DomainEventDispatcher(
IServiceProvider serviceProvider)
{
public async Task DispatchAsync(
IEnumerable<IDomainEvent> events,
CancellationToken ct)
{
foreach (var domainEvent in events)
{
var handlerType = typeof(IDomainEventHandler<>)
.MakeGenericType(domainEvent.GetType());
var handlers = serviceProvider.GetServices(handlerType);
foreach (var handler in handlers)
{
await ((dynamic)handler).HandleAsync(
(dynamic)domainEvent, ct);
}
}
}
}
// Note: The (dynamic) dispatch pattern is simple but not AOT-compatible.
// For Native AOT scenarios, use a source-generated or dictionary-based
// dispatcher. See [skill:dotnet-native-aot] for AOT constraints.
// Handler interface
public interface IDomainEventHandler<in TEvent>
where TEvent : IDomainEvent
{
Task HandleAsync(TEvent domainEvent, CancellationToken ct);
}
保存时分发事件
使用 EF Core SaveChangesInterceptor 或包装器,在保存后分发事件:
public sealed class EventDispatchingSaveChangesInterceptor(
DomainEventDispatcher dispatcher)
: SaveChangesInterceptor
{
public override async ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData,
int result,
CancellationToken ct)
{
if (eventData.Context is not null)
{
var aggregates = eventData.Context.ChangeTracker
.Entries<AggregateRoot<Guid>>()
.Where(e => e.Entity.DomainEvents.Count > 0)
.Select(e => e.Entity)
.ToList();
var events = aggregates
.SelectMany(a => a.DomainEvents)
.ToList();
foreach (var aggregate in aggregates)
{
aggregate.ClearDomainEvents();
}
await dispatcher.DispatchAsync(events, ct);
}
return result;
}
}
领域事件与集成事件
| 方面 | 领域事件 | 集成事件 |
|---|---|---|
| 范围 | 在限界上下文内 | 跨限界上下文/服务 |
| 传输方式 | 进程内(分发器) | 消息代理(Service Bus、RabbitMQ) |
| 耦合 | 引用领域类型 | 仅使用基本类型/DTO 类型 |
| 可靠性 | 同一事务范围 | 至少一次投递,由幂等消费者处理 |
| 示例 | OrderSubmitted(触发电子邮件处理程序) | OrderSubmittedIntegration(通知配送服务) |
领域事件处理程序可以向消息代理发布集成事件。有关集成事件基础设施,请参阅 [skill:dotnet-messaging-patterns]。
// Domain event handler that publishes an integration event
public sealed class OrderSubmittedHandler(
IPublishEndpoint publishEndpoint)
: IDomainEventHandler<OrderSubmitted>
{
public async Task HandleAsync(
OrderSubmitted domainEvent, CancellationToken ct)
{
// Map domain event to integration event (no domain types)
await publishEndpoint.Publish(
new OrderSubmittedIntegration(
domainEvent.OrderId,
domainEvent.Total.Amount,
domainEvent.Total.Currency),
ct);
}
}
充血模型与贫血模型
充血领域模型
业务逻辑位于领域实体内部。方法负责强制执行不变量并返回有意义的结果:
public sealed class ShoppingCart : AggregateRoot<Guid>
{
private readonly List<CartItem> _items = [];
public IReadOnlyList<CartItem> Items => _items.AsReadOnly();
public void AddItem(ProductId productId, int quantity, Money unitPrice)
{
var existing = _items.Find(i => i.ProductId == productId);
if (existing is not null)
{
existing.IncreaseQuantity(quantity);
}
else
{
_items.Add(new CartItem(productId, quantity, unitPrice));
}
}
public void RemoveItem(ProductId productId)
{
var item = _items.Find(i => i.ProductId == productId)
?? throw new DomainException(
$"Product {productId} not in cart.");
_items.Remove(item);
}
public Money GetTotal(string currency) =>
_items.Aggregate(
Money.Zero(currency),
(sum, item) => sum.Add(item.LineTotal));
}
贫血领域模型(反模式)
实体只是带有公共 setter 的数据容器。业务逻辑位于外部服务中:
// ANTI-PATTERN: Entity is just a data container
public class ShoppingCart
{
public Guid Id { get; set; }
public List<CartItem> Items { get; set; } = [];
}
// All logic lives here -- the entity has no behavior
public class ShoppingCartService
{
public void AddItem(ShoppingCart cart, string productId,
int quantity, decimal unitPrice)
{
var existing = cart.Items.Find(i => i.ProductId == productId);
if (existing != null)
existing.Quantity += quantity;
else
cart.Items.Add(new CartItem { ... });
}
}
选择指南
| 因素 | 充血模型 | 贫血模型 |
|---|---|---|
| 复杂不变量 | 在实体中强制执行 | 分散在各个服务中 |
| 可测试性 | 直接测试实体行为 | 同时测试服务和实体 |
| 易发现性 | 实体上的方法展示其能力 | 必须找到合适的服务类 |
| 持久化耦合 | 需要 ORM 友好的私有 setter | 属性映射简单 |
| 团队熟悉度 | 需要 DDD 经验 | 大多数开发者都熟悉 |
建议: 对业务规则复杂的聚合,优先采用充血模型。对于领域逻辑极少的简单 CRUD 实体(例如参考数据、配置记录),可以采用贫血模型。
领域服务
领域服务封装不自然属于单个实体或值对象的业务逻辑。它们处理领域类型并强制执行跨聚合规则。
public sealed class PricingService
{
public Money CalculateDiscount(
Order order,
CustomerTier tier,
IReadOnlyList<PromotionRule> activePromotions)
{
var discount = Money.Zero(order.Total.Currency);
// Tier-based discount
discount = tier switch
{
CustomerTier.Gold => discount.Add(
order.Total.Multiply(0.10m)),
CustomerTier.Platinum => discount.Add(
order.Total.Multiply(0.15m)),
_ => discount
};
// Promotion-based discounts
foreach (var promo in activePromotions)
{
if (promo.AppliesTo(order))
{
discount = discount.Add(promo.Calculate(order));
}
}
return discount;
}
}
何时使用领域服务
- 逻辑需要来自多个聚合的数据,而这些聚合不应相互引用
- 业务规则不属于任何单个实体(例如跨产品和客户等级的定价规则)
- 外部策略或配置决定逻辑(例如税费计算规则)
领域服务应保持纯粹——不依赖基础设施。如果逻辑需要数据库或外部 API,应将其放在应用服务中,由应用服务使用预加载的数据调用领域服务。
仓储契约
仓储接口属于领域层,用于表达聚合的加载和保存语义。实现细节(EF Core、Dapper)位于基础设施层。
// Domain layer -- defines the contract
public interface IOrderRepository
{
Task<Order?> FindByIdAsync(Guid id, CancellationToken ct);
Task AddAsync(Order order, CancellationToken ct);
Task SaveChangesAsync(CancellationToken ct);
}
// Domain layer -- unit of work abstraction (optional)
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken ct);
}
有关 EF Core 仓储实现,请参阅 [skill:dotnet-efcore-architecture]。
仓储设计规则
| 规则 | 理由 |
|---|---|
| 每个聚合根对应一个仓储 | 通过根访问子实体 |
不返回 IQueryable<T> 类型 | 防止持久化问题泄漏到领域层 |
不使用通用的 IRepository<T> | 无法表达特定于聚合的加载规则 |
| 返回领域类型,而非 DTO | 仓储服务于领域;读取模型使用投影 |
所有异步方法都包含 CancellationToken | 正确传播取消请求所必需 |
领域异常
使用特定于领域的异常来表示不变量违反。这可将领域错误与基础设施错误区分开来:
public class DomainException : Exception
{
public DomainException(string message) : base(message) { }
public DomainException(string message, Exception inner)
: base(message, inner) { }
}
// Specific domain exceptions for different invariant violations
public sealed class InsufficientStockException(
ProductId productId, int requested, int available)
: DomainException(
$"Insufficient stock for {productId}: " +
$"requested {requested}, available {available}")
{
public ProductId ProductId => productId;
public int Requested => requested;
public int Available => available;
}
在 API 边界将领域异常映射为 HTTP 响应(例如,将 DomainException 映射为 422 Unprocessable Entity)。不要让 HTTP 状态码等基础设施问题泄漏到领域层。
Agent 常见陷阱
- 不要为聚合属性公开 setter——所有状态变更都必须通过聚合根上的方法进行,以确保满足不变量。属性应使用
private set或init。 - 不要在聚合根之间创建导航属性——通过 ID 值对象(例如
CustomerId)引用其他聚合,而不是使用实体导航。跨聚合导航会破坏限界上下文隔离。 - 不要在事务内分发领域事件——应在
SaveChangesAsync成功后分发。在保存前分发意味着即使保存失败,副作用也会触发。 - 不要在集成事件中使用领域类型——集成事件会跨越限界上下文边界,必须使用基本类型或 DTO。领域类型变更会破坏其他服务。
- 不要只在 API 层实现验证逻辑——领域不变量属于领域模型。API 验证([skill:dotnet-validation-patterns])用于捕获格式错误的输入;领域验证用于确保业务规则得到遵守。
- 不要创建带有公开
List<T>属性的贫血实体——将集合公开为IReadOnlyList<T>,并在聚合根上提供能够确保业务规则得到遵守的变更方法。 - 不要将基础设施服务注入领域实体——实体应是纯 C# 对象。需要外部数据的逻辑应使用领域服务,基础设施编排应使用应用服务。