dotnet-starter-kit 添加领域实体完全指南:AggregateRoot、领域事件与 EF 迁移实战

原创2026-09-16 14:41:2337 阅读
文章标签:后端前端示例工程认证鉴权

这篇指南以 dotnet-starter-kit 仓库内置的 add-entity 技能(SKILL.md)为骨架,完整讲解如何在 FSH 模块中新增一个带 EF 配置与数据库迁移的领域实体/聚合根。读完你可以掌握从领域模型设计、领域事件、IEntityTypeConfiguration 到迁移生成的全流程,并能理解拦截器与全局查询过滤器在背后的自动行为。


一、适用范围与前置约定

add-entity 技能面向"向既有 FSH 模块添加新的数据库持久化实体"这一场景,与仓库中的 add-feature(添加 CQRS 功能)和 create-migration(创建迁移)两个技能配套使用,调用方式为:

[ModuleName] [EntityName]

使用前需遵守两条硬性约定:

  • Rich domain model(富领域模型):实体必须是 sealed 聚合根,采用私有 EF 构造函数、静态工厂方法、行为通过方法暴露、通过领域事件向外通知;
  • 数据库约定:遵循仓库 .agents/rules/database.md 中的数据库规范(下文的过滤索引、ValueGeneratedNever 等即来自该约定)。

当前仓库中的典型示范位于各业务模块的 Domain 目录,例如 BillingPlan.csWalletTransaction.cs 等,均遵循同一模式,可作为对照样本。


二、实体设计:AggregateRoot<Guid> 还是 BaseEntity<Guid>

技能给出的实体骨架如下:

public sealed class {Entity} : AggregateRoot<Guid>, IHasTenant, IAuditableEntity, ISoftDeletable
{
    public string Name { get; private set; } = default!;
    public Money Price { get; private set; } = default!;

    // IHasTenant
    public string TenantId { get; private set; } = default!;
    // IAuditableEntity
    public DateTimeOffset CreatedOnUtc { get; set; }
    public string? CreatedBy { get; set; }
    public DateTimeOffset? LastModifiedOnUtc { get; set; }
    public string? LastModifiedBy { get; set; }
    // ISoftDeletable
    public bool IsDeleted { get; set; }
    public DateTimeOffset? DeletedOnUtc { get; set; }
    public string? DeletedBy { get; set; }

    private {Entity}() { }   // EF

    public static {Entity} Create(string name, Money price)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
        ArgumentNullException.ThrowIfNull(price);

        var entity = new {Entity} { Id = Guid.CreateVersion7(), Name = name.Trim(), Price = price };
        entity.AddDomainEvent(DomainEvent.Create((id, ts) =>
            new {Entity}CreatedDomainEvent(entity.Id, entity.Name, id, ts)));
        return entity;
    }

    public void Rename(string name)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
        Name = name.Trim();
    }
}

2.1 基类职责边界

在源码中,BaseEntity.cs 只提供两样东西:

  • Idprotected set);
  • 领域事件容器:DomainEvents 只读集合、受保护的 AddDomainEvent、以及 ClearDomainEvents
public abstract class BaseEntity<TId> : IEntity<TId>, IHasDomainEvents
{
    private readonly List<IDomainEvent> _domainEvents = [];
    public TId Id { get; protected set; } = default!;
    public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents;
    protected void AddDomainEvent(IDomainEvent @event) => _domainEvents.Add(@event);
    public void ClearDomainEvents() => _domainEvents.Clear();
}

AggregateRoot.cs 只是 BaseEntity<TId> 的语义化子类(当前没有额外成员),用来表达"这是一个聚合根",后续聚合级行为/辅助方法可以统一放进去。

关键点:基类不携带审计字段、租户字段和软删除字段——这些全部是通过标记接口(marker interfaces)按需加入的。这意味着只有当你显式实现 IHasTenantIAuditableEntityISoftDeletable 时,实体才会获得多租户、审计追踪与软删除能力,保持领域模型最小化。

2.2 标记接口与字段语义

三个接口定义在 src/BuildingBlocks/Core/Domain 下:

接口 字段 含义
IHasTenant string TenantId 关联租户(框架通过 Finbuckle 自动写入)
IAuditableEntity CreatedOnUtcCreatedByLastModifiedOnUtcLastModifiedBy 审计时间与操作人
ISoftDeletable IsDeletedDeletedOnUtcDeletedBy 软删除标记与删除元数据

例如 IHasTenant.cs 只声明了只读的 TenantId,而 IAuditableEntity.csISoftDeletable.cs 同理,全部是"只读契约 + 框架写入"的设计。

2.3 属性可见性的两个反直觉点

  • 业务属性用 private setNamePrice 等只能通过静态工厂或行为方法(如 Rename)修改,外部无法直接赋值,这是富领域模型的核心;
  • 框架字段不用 private setTenantId、审计字段、软删除字段不要设成 private set,因为它们由框架自动写入——AuditableEntitySaveChangesInterceptor 负责审计与软删除,Finbuckle 负责 TenantId(详见第五节)。

2.4 主键:Guid.CreateVersion7(),禁用 Guid.NewGuid()

技能明确要求:新主键一律使用 Guid.CreateVersion7(),绝不使用 Guid.NewGuid()。Version 7 GUID 是时间有序的,能显著改善数据库索引写入的聚簇性、降低页分裂。这一点在仓库中得到广泛印证:

由于 Id 由应用侧赋值且是顺序值,实体配置中往往需要配合 ValueGeneratedNever()(见第四节 4.4)。

2.5 值对象:Money

骨架中的 Money 是仓库内置的金额值对象,定义在 Money.cs,由 AmountCurrency 组成,在 EF 中通过 OwnsOne 映射为两个列(PriceAmountPriceCurrency),并搭配 HasPrecision(18, 4) 保证金额精度。


三、领域事件:继承抽象 record DomainEvent

3.1 事件类型定义

领域事件必须是继承 DomainEventsealed record

public sealed record {Entity}CreatedDomainEvent(
    Guid {Entity}Id, string Name, Guid EventId, DateTimeOffset OccurredOnUtc)
    : DomainEvent(EventId, OccurredOnUtc);

在源码中,DomainEvent.cs 是一个抽象 record,除了 EventIdOccurredOnUtc,还预留了可选的 CorrelationIdTenantId 用于链路追踪和租户上下文:

public abstract record DomainEvent(
    Guid EventId,
    DateTimeOffset OccurredOnUtc,
    string? CorrelationId = null,
    string? TenantId = null
) : IDomainEvent;

3.2 抛出方式:DomainEvent.Create + AddDomainEvent

技能强调:必须用 DomainEvent.Create((id, ts) => …) 辅助方法配合 AddDomainEvent(...) 抛出,而不是 QueueDomainEventDomainEvent.Create<T> 的源码:

public static T Create<T>(Func<Guid, DateTimeOffset, T> factory) where T : DomainEvent
{
    ArgumentNullException.ThrowIfNull(factory);
    return factory(Guid.NewGuid(), DateTimeOffset.UtcNow);
}

它会自动生成事件 ID 与 UTC 时间戳并传给工厂函数,随后实体通过 AddDomainEvent 将事件登记进 BaseEntity_domainEvents 列表,等待 DomainEventsInterceptorSaveChangesAsync 成功后统一经 Mediator 的 IPublisher.Publish 派发。拦截器在派发前会先 ClearDomainEvents,且单个处理器失败不会回滚已提交的事务——需要保证投递的场景应使用 outbox 模式(仓库 Eventing 模块支持)。


四、EF 配置:IEntityTypeConfiguration<T>

public sealed class {Entity}Configuration : IEntityTypeConfiguration<{Entity}>
{
    public void Configure(EntityTypeBuilder<{Entity}> builder)
    {
        ArgumentNullException.ThrowIfNull(builder);
        builder.ToTable("{Entities}");                       // schema is set once on the DbContext
        builder.HasKey(x => x.Id);
        builder.Property(x => x.Name).IsRequired().HasMaxLength(200);

        // soft-deletable unique field → filter on live rows only
        builder.HasIndex(x => x.Name).IsUnique().HasFilter("\"IsDeleted\" = FALSE");

        // owned value object
        builder.OwnsOne(x => x.Price, m =>
        {
            m.Property(p => p.Amount).HasColumnName("PriceAmount").HasPrecision(18, 4);
            m.Property(p => p.Currency).HasColumnName("PriceCurrency").HasMaxLength(3);
        });

        builder.Ignore(x => x.DomainEvents);
    }
}

各配置项要点:

配置 说明
ToTable("{Entities}") 表名用复数;schema 统一在 DbContext 上设置一次,此处不写 schema
HasKey(x => x.Id) 显式声明主键
IsRequired().HasMaxLength(200) 必填 + 长度约束
过滤唯一索引 软删除实体上的唯一字段,必须 HasFilter("\"IsDeleted\" = FALSE"),让唯一约束只作用于未删除行——这样"删除后允许重建同名记录"
OwnsOne 值对象映射为独立列(PriceAmountPriceCurrency),HasPrecision(18, 4) 保证精度,HasMaxLength(3) 匹配 ISO 货币码
Ignore(DomainEvents) 领域事件集合不落库(它仅存在于内存中)

4.1 不要手动添加软删除/租户过滤器

技能明确警告:不要在实体配置里手动 HasQueryFilter 过滤软删除或租户。原因在 BaseDbContext.csOnModelCreating 中:

modelBuilder.AppendGlobalQueryFilter<ISoftDeletable>(QueryFilters.SoftDelete, s => !s.IsDeleted);
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyTenantIsolationByDefault();
  • 软删除过滤器由框架统一追加(命名为 SoftDelete,见 QueryFilters.cs),过滤 IsDeleted == true 的行;
  • 租户隔离默认开启:所有未标记 IGlobalEntity 的实体自动获得 IsMultiTenant()
  • 子类必须让 base.OnModelCreatingApplyConfigurationsFromAssembly 之后被调用,这样按实体逐个加载的配置才能先就位、再叠加全局过滤器。所以你的模块 DbContext 不要改动这一顺序。

如需在"回收站/恢复"等场景绕过软删除过滤器,使用命名过滤器的定点关闭能力 IgnoreQueryFilters([QueryFilters.SoftDelete])QueryFilters 类就是为此提供稳定名称的),而不是把实体上所有过滤器一起剥掉。

4.2 导航集合子实体:ValueGeneratedNever()

技能指出的经典陷阱:仅通过父实体导航集合到达的子实体,需要在它自己的配置里写 builder.Property(x => x.Id).ValueGeneratedNever(),否则 EF 会把应用侧赋值(非默认值)的 Guid 误判为"已持久化",从而把插入错当成 Modified 状态,最终生成 0 行 UPDATE 或触发并发异常。

仓库中有两处可直接对照的实现注释:

  • ProductImageConfiguration.csId 由应用赋值(Guid.CreateVersion7),不写 ValueGeneratedNever 就会导致"通过已跟踪父级导航集合到达的非默认 Guid 被当作已持久化 → UPDATE 0 行 → 并发异常";
  • WalletTransactionConfiguration.cs:同样标注"子实体仅能经 Wallet.Transactions 导航到达——必须固定 Id 生成,否则 EF 标记为 Modified 而非 Added"。

五、注册到模块 DbContext

在模块的 DbContext 中增加一个 DbSet 即可:

public DbSet<{Entity}> {Entities} => Set<{Entity}>();

无需逐个手动注册配置类——模块 DbContext 会通过 ApplyConfigurationsFromAssembly 自动发现并应用同一程序集中的全部 IEntityTypeConfiguration。DbContext 本身已继承 BaseDbContext最后调用 base.OnModelCreating,保持默认即可。

背后的自动行为由两个拦截器完成(它们在 SaveChanges 时生效):

  • AuditableEntitySaveChangesInterceptor.cs
    • IAuditableEntityAdded 时写入 CreatedOnUtc/CreatedByModified 或 owned 子对象变更时写入 LastModifiedOnUtc/LastModifiedBy(操作人取当前登录用户 ID);
    • ISoftDeletableDeleted 时把状态改为 Modified 并置 IsDeleted = true、记录 DeletedOnUtc/DeletedBy,实现软删除;同时把级联删除的 owned 引用恢复为 Unchanged,避免生成 UPDATE NULL 触发 NOT NULL 违反(拦截器注释中明确记录了曾因此破坏 Product.Price/Money 的历史问题);
    • 使用 AsyncLocal 递归守卫防止嵌套 SaveChanges 导致栈溢出。
  • DomainEventsInterceptor.cs:在保存成功后收集所有 IHasDomainEvents 实体上挂起的事件、清空集合并经 Mediator 发布。

六、创建迁移

实体与配置就绪后,使用 create-migration 技能生成迁移。技能给出的是 PostgreSQL 迁移项目下的命令模板:

dotnet ef migrations add Add{Entity} \
  --project src/Host/FSH.Starter.Migrations.PostgreSQL \
  --startup-project src/Host/FSH.Starter.Api \
  --context {X}DbContext

执行前的两个必要条件:

  1. 先构建通过dotnet build),确保实体、配置、DbContext 均编译无误;
  2. --context 必须指向你的模块 DbContext(替换 {X}DbContext),而不是 BaseDbContext——迁移是按模块上下文隔离的。

迁移会落在 src/Host/FSH.Starter.Migrations.PostgreSQL 项目对应模块的迁移目录中(如 Billing/Catalog/Identity/ 等,每个模块一个迁移子目录),并与模块的迁移程序集 MigrationsAssembly 对应。


七、完成清单(Checklist)

技能为每次"加实体"操作给出了一份可勾选的验收清单,写作时请逐项对照:

  • [ ] 实体为 sealed,继承 AggregateRoot<Guid>(按需实现 IHasTenant/IAuditableEntity/ISoftDeletable),私有无参构造函数供 EF 使用,静态工厂 Create 内使用 Guid.CreateVersion7() 生成主键;
  • [ ] 领域事件继承 DomainEvent,通过 DomainEvent.Create + AddDomainEvent 抛出(非 QueueDomainEvent);
  • [ ] EF 配置中:不手动添加软删除/租户查询过滤器;仅通过父导航到达的子实体配置 ValueGeneratedNever()
  • [ ] 已添加 DbSet、构建通过,并使用 --context {X}DbContext 创建了迁移。

八、小结

add-entity 技能把 dotnet-starter-kit 中"新增数据库实体"的完整套路固化成了可复制的最小步骤:富领域模型(sealed + 私有构造函数 + 静态工厂 + 行为方法 + 领域事件)→ 按需标记接口获得租户/审计/软删除能力 → IEntityTypeConfiguration 精确控制表结构与索引 → 模块 DbContext 的 DbSet 自动装配 → 在 PostgreSQL 迁移项目生成迁移。理解背后 BaseDbContext 的全局过滤器与两个 SaveChanges 拦截器,你就能在写实体时放心地把"样板活"交给框架,只专注于领域本身的建模。

dotnet-starter-kit