mcpField
D
dotnet-domain-modeling

dotnet-domain-modeling

github.com/wshaddix开发86536 次安装35 周前更新
代码仓库

关于 dotnet-domain-modeling

面向 C# 开发者的领域驱动设计战术模式指南,聚焦与持久化技术无关的纯领域模型。内容涵盖聚合、值对象、领域事件及仓储契约,并讨论丰富模型与贫血模型的取舍。

主要功能

  1. 01设计聚合根与实体
  2. 02使用值对象表达领域概念
  3. 03定义及分发领域事件
  4. 04区分领域事件与集成事件
  5. 05设计领域服务和仓储契约
  6. 06比较丰富与贫血领域模型

适用场景

  1. 01为复杂业务规则划分聚合边界
  2. 02在领域模型中表达约束与值语义
  3. 03规划领域事件及副作用处理
  4. 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 常见陷阱

  1. 不要为聚合属性公开 setter——所有状态变更都必须通过聚合根上的方法进行,以确保满足不变量。属性应使用 private set 或 init。
  2. 不要在聚合根之间创建导航属性——通过 ID 值对象(例如 CustomerId)引用其他聚合,而不是使用实体导航。跨聚合导航会破坏限界上下文隔离。
  3. 不要在事务内分发领域事件——应在 SaveChangesAsync 成功后分发。在保存前分发意味着即使保存失败,副作用也会触发。
  4. 不要在集成事件中使用领域类型——集成事件会跨越限界上下文边界,必须使用基本类型或 DTO。领域类型变更会破坏其他服务。
  5. 不要只在 API 层实现验证逻辑——领域不变量属于领域模型。API 验证([skill:dotnet-validation-patterns])用于捕获格式错误的输入;领域验证用于确保业务规则得到遵守。
  6. 不要创建带有公开 List<T> 属性的贫血实体——将集合公开为 IReadOnlyList<T>,并在聚合根上提供能够确保业务规则得到遵守的变更方法。
  7. 不要将基础设施服务注入领域实体——实体应是纯 C# 对象。需要外部数据的逻辑应使用领域服务,基础设施编排应使用应用服务。

参考资料

评论

支持 Markdown