dotnet-starter-kit 添加领域实体完全指南:AggregateRoot、领域事件与 EF 迁移实战
这篇指南以 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.cs、WalletTransaction.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 只提供两样东西:
Id(protected 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)按需加入的。这意味着只有当你显式实现 IHasTenant、IAuditableEntity、ISoftDeletable 时,实体才会获得多租户、审计追踪与软删除能力,保持领域模型最小化。
2.2 标记接口与字段语义
三个接口定义在 src/BuildingBlocks/Core/Domain 下:
| 接口 | 字段 | 含义 |
|---|---|---|
IHasTenant |
string TenantId |
关联租户(框架通过 Finbuckle 自动写入) |
IAuditableEntity |
CreatedOnUtc、CreatedBy、LastModifiedOnUtc、LastModifiedBy |
审计时间与操作人 |
ISoftDeletable |
IsDeleted、DeletedOnUtc、DeletedBy |
软删除标记与删除元数据 |
例如 IHasTenant.cs 只声明了只读的 TenantId,而 IAuditableEntity.cs、ISoftDeletable.cs 同理,全部是"只读契约 + 框架写入"的设计。
2.3 属性可见性的两个反直觉点
- 业务属性用
private set:Name、Price等只能通过静态工厂或行为方法(如Rename)修改,外部无法直接赋值,这是富领域模型的核心; - 框架字段不用
private set:TenantId、审计字段、软删除字段不要设成private set,因为它们由框架自动写入——AuditableEntitySaveChangesInterceptor负责审计与软删除,Finbuckle 负责TenantId(详见第五节)。
2.4 主键:Guid.CreateVersion7(),禁用 Guid.NewGuid()
技能明确要求:新主键一律使用 Guid.CreateVersion7(),绝不使用 Guid.NewGuid()。Version 7 GUID 是时间有序的,能显著改善数据库索引写入的聚簇性、降低页分裂。这一点在仓库中得到广泛印证:
- BillingPlan.cs、Invoice.cs、Wallet.cs 等 Billing 模块实体;
- Audit.cs 以及
AuditingSaveChangesInterceptor中也使用Guid.CreateVersion7()生成事件 ID。
由于 Id 由应用侧赋值且是顺序值,实体配置中往往需要配合 ValueGeneratedNever()(见第四节 4.4)。
2.5 值对象:Money
骨架中的 Money 是仓库内置的金额值对象,定义在 Money.cs,由 Amount 与 Currency 组成,在 EF 中通过 OwnsOne 映射为两个列(PriceAmount、PriceCurrency),并搭配 HasPrecision(18, 4) 保证金额精度。
三、领域事件:继承抽象 record DomainEvent
3.1 事件类型定义
领域事件必须是继承 DomainEvent 的 sealed record:
public sealed record {Entity}CreatedDomainEvent(
Guid {Entity}Id, string Name, Guid EventId, DateTimeOffset OccurredOnUtc)
: DomainEvent(EventId, OccurredOnUtc);
在源码中,DomainEvent.cs 是一个抽象 record,除了 EventId 和 OccurredOnUtc,还预留了可选的 CorrelationId 与 TenantId 用于链路追踪和租户上下文:
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(...) 抛出,而不是 QueueDomainEvent。DomainEvent.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 列表,等待 DomainEventsInterceptor 在 SaveChangesAsync 成功后统一经 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 |
值对象映射为独立列(PriceAmount、PriceCurrency),HasPrecision(18, 4) 保证精度,HasMaxLength(3) 匹配 ISO 货币码 |
Ignore(DomainEvents) |
领域事件集合不落库(它仅存在于内存中) |
4.1 不要手动添加软删除/租户过滤器
技能明确警告:不要在实体配置里手动 HasQueryFilter 过滤软删除或租户。原因在 BaseDbContext.cs 的 OnModelCreating 中:
modelBuilder.AppendGlobalQueryFilter<ISoftDeletable>(QueryFilters.SoftDelete, s => !s.IsDeleted);
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyTenantIsolationByDefault();
- 软删除过滤器由框架统一追加(命名为
SoftDelete,见 QueryFilters.cs),过滤IsDeleted == true的行; - 租户隔离默认开启:所有未标记
IGlobalEntity的实体自动获得IsMultiTenant(); - 子类必须让
base.OnModelCreating在ApplyConfigurationsFromAssembly之后被调用,这样按实体逐个加载的配置才能先就位、再叠加全局过滤器。所以你的模块 DbContext 不要改动这一顺序。
如需在"回收站/恢复"等场景绕过软删除过滤器,使用命名过滤器的定点关闭能力 IgnoreQueryFilters([QueryFilters.SoftDelete])(QueryFilters 类就是为此提供稳定名称的),而不是把实体上所有过滤器一起剥掉。
4.2 导航集合子实体:ValueGeneratedNever()
技能指出的经典陷阱:仅通过父实体导航集合到达的子实体,需要在它自己的配置里写 builder.Property(x => x.Id).ValueGeneratedNever(),否则 EF 会把应用侧赋值(非默认值)的 Guid 误判为"已持久化",从而把插入错当成 Modified 状态,最终生成 0 行 UPDATE 或触发并发异常。
仓库中有两处可直接对照的实现注释:
- ProductImageConfiguration.cs:
Id由应用赋值(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:
- 对
IAuditableEntity在Added时写入CreatedOnUtc/CreatedBy,Modified或 owned 子对象变更时写入LastModifiedOnUtc/LastModifiedBy(操作人取当前登录用户 ID); - 对
ISoftDeletable在Deleted时把状态改为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
执行前的两个必要条件:
- 先构建通过(
dotnet build),确保实体、配置、DbContext 均编译无误; --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 拦截器,你就能在写实体时放心地把"样板活"交给框架,只专注于领域本身的建模。