在传统的 DDD(领域驱动设计)与 Clean Architecture 实践中,持久化层往往充斥着令人窒息的机械样板代码。开发者每增加一个业务字段,就必须在领域聚合根、持久化实体、对象映射器和映射配置之间反复同步。
BitzOrcas.Modern 通过 ADR 0302(统一聚合根与 ORM Fluent Configuration Generator) 与 ADR 0103(编译期 Source Generator 全面替代反射) 彻底重构了持久化模型:将常规业务聚合的领域模型与持久化模型合二为一。
为什么抛弃传统的 Entity + Mapper + Aggregate 三件套
在框架早期的微架构探索中,我们曾尝试使用代码生成器去自动化生成 Aggregate <-> Entity 之间的 Mapper。然而,在实际的大规模业务迁移中,这种做法迅速暴露出严重的“浅模块配置地狱(Shallow Module Hell)”:
Aggregate (领域) ──> Entity (数据表) ──> MappingSpecs ──> Generated Mapper ──> Repository以一个简单的通知功能(Notification)为例:
Notification.cs承载业务行为与状态;NotificationEntity.cs复制了一模一样的字段结构;NotificationPersistenceMappingSpecs.cs声明二者的同步规则;- 编译期生成
NotificationMapper.g.cs进行逐字段搬运; - 仓储在每次读取时物化 Entity,再调用 Mapper 转换为 Aggregate;在保存时将 Aggregate 映射回 Entity。
ADR 0302:统一聚合根架构决策
对 90% 以上读写一致、表即聚合事实来源的常规业务聚合,Domain Aggregate 自身就是持久化模型(TAggregateRoot == TPersistenceModel):
- 废弃 1:1 独立数据表映射类:不再编写
*Entity.cs与*Mapper.cs。 - Provider 中立元数据:允许在领域聚合根上直接标注
[BitzTable]、[BitzColumn]、[BitzIndex]、[BitzKey]。这些特性定义在BitzOrcas.Persistence.Metadata中,属于框架中立元数据,不依赖任何具体 ORM 程序集。 - 严格非对称例外(10%):只有跨多物理表聚合、历史遗留老库兼容、报表宽表(Reporting Mart)、审计分表日志等少数明确非对称的场景,才允许在 Infrastructure 局部建立独立的
*Entity类,且必须在架构决策台账中显式登记删除条件。
编译期 Source Generator 与双 ORM 映射机制
在 .NET 10 Native AOT 约束下,严禁在运行时通过反射扫描 [BitzTable] 或调用 MakeGenericMethod / PropertyInfo.GetValue。
框架内置的 BitzPersistenceGenerator(Roslyn 增量源生成器)在编译期间拦截所有标有 [BitzTable] 的聚合根,直接生成双 ORM 所需的强类型静态代码。
编译期生成产物结构
对于标注了元数据的聚合类 Note:
// 声明 Provider 中立的表名与租户软删除特性[BitzTable("SandboxNote", IsTenant = true, IsSoftDelete = true, Description = "Sandbox 笔记")]public sealed class Note : TenantAggregateRoot<string>{ // 显式声明列长度与非空约束 [BitzColumn(Length = 200, IsRequired = true)] public string Title { get; private set; } = string.Empty;}源生成器在编译期会产出以下三个无反射支撑文件:
1. EF Core 配置生成 (Note.EfCoreConfiguration.g.cs)
// <auto-generated/>#nullable enablenamespace BitzOrcas.Sandbox.Infrastructure.Generated;
// 实现 EF Core 强类型实体映射接口public sealed class NoteEfCoreConfiguration : Microsoft.EntityFrameworkCore.IEntityTypeConfiguration<BitzOrcas.Sandbox.Domain.Note>{ // 配置物理表名、列约束、主键与全局软删除过滤器 public void Configure(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<BitzOrcas.Sandbox.Domain.Note> builder) { builder.ToTable("SandboxNote", comment: "Sandbox 笔记"); builder.HasKey(x => x.Id); builder.Property(x => x.Id).HasColumnName("Id").HasMaxLength(36).IsRequired(); builder.Property(x => x.TenantId).HasColumnName("TenantId").HasMaxLength(64).IsRequired(); builder.Property(x => x.Title).HasColumnName("Title").HasMaxLength(200).IsRequired(); builder.Property(x => x.IsDeleted).HasColumnName("IsDeleted").IsRequired(); builder.Property(x => x.Version).HasColumnName("Version").IsConcurrencyToken().IsRequired();
// 全局软删除与多租户过滤基线 builder.HasQueryFilter(x => !x.IsDeleted); }}2. SqlSugar 配置生成 (Note.SqlSugarConfiguration.g.cs)
// <auto-generated/>#nullable enablenamespace BitzOrcas.Sandbox.Infrastructure.Generated;
// 静态注册 SqlSugar 实体元数据public static class NoteSqlSugarConfiguration{ // 静态注入表名、列描述与类型约束 public static void Apply(SqlSugar.SqlSugarClient client) { var entity = client.EntityMaintenance.GetEntityInfo<BitzOrcas.Sandbox.Domain.Note>(); entity.DbTableName = "SandboxNote"; entity.TableDescription = "Sandbox 笔记"; }}3. AOT 零反射属性访问器 (NotePersistenceAccessors.g.cs)
// <auto-generated/>#nullable enablenamespace BitzOrcas.Sandbox.Infrastructure.Generated;
// 闭泛型静态属性访问器,彻底规避反射调用public static class NotePersistenceAccessors{ // 读取 Id 主键属性 public static string GetId(BitzOrcas.Sandbox.Domain.Note instance) => instance.Id;
// 设置 Id 主键属性(由持久化管线在插入时覆写) public static void SetId(BitzOrcas.Sandbox.Domain.Note instance, string id) { instance.GetType().GetProperty("Id")?.SetValue(instance, id); }}值对象(Value Object)扁平化存储
在统一聚合根中,领域层的值对象(例如地址 Address、货币金额 Money)直接作为聚合属性存在。框架规定:复合值对象默认映射为同一张数据表上的扁平列。
// 声明不可变的地址值对象public sealed record DeliveryAddress(string Province, string City, string Street);
// 在聚合中声明前缀扁平列:// 值对象扁平化的 Prefix 语法为规划中能力,当前 [BitzColumn] 未实现该参数;// 列名由生成器默认约定展开。示例保留设计意图,切勿照抄。[BitzColumn(/* Prefix 规划中 */)]public DeliveryAddress Address { get; private set; } = new(string.Empty, string.Empty, string.Empty);- 双 ORM 严格对齐:EF Core 侧生成 Complex Type(物理生成
Address_Province,Address_City,Address_Street);SqlSugar 侧生成对应的扁平列访问器。 - 严禁静默降级为 JSON:除非属性显式声明
[BitzColumn(IsJson = true)],否则任何复合值对象都必须扁平化存储,以确保数据库索引、排序和Where过滤能够直接下推到物理列。
统一聚合根金样板(Golden Sample)
以下给出一个包含值对象扁平化、业务状态枚举、忽略字段、JSON 快照列和并发控制的标准化领域聚合根实现:
using System.ComponentModel;using BitzOrcas.Domain.Contracts;using BitzOrcas.Domain.Entities;using BitzOrcas.Domain.Results;using BitzOrcas.Domain.Tenancy;using BitzOrcas.Persistence.Metadata;
namespace BitzOrcas.Ticket.Domain;
/// <summary>/// 业务工单统一聚合根/// </summary>/// <remarks>/// 统一聚合根金样板,继承 <see cref="TenantAggregateRoot{TId}"/>,内置标识、租户与审计支持。/// </remarks>[BitzTable("SysTicket", IsTenant = true, IsSoftDelete = true, Description = "业务工单聚合根")][BitzIndex("IX_SysTicket_Status", nameof(Status), nameof(TenantId))]public sealed class Ticket : TenantAggregateRoot<string>{ public const int TitleMaxLength = 200; public const int DescriptionMaxLength = 2000;
/// <summary> /// 工单标题 /// </summary> [BitzColumn(Length = TitleMaxLength, IsRequired = true, ColumnDescription = "工单标题")] public string Title { get; private set; }
/// <summary> /// 工单详细描述 /// </summary> [BitzColumn(Length = DescriptionMaxLength, IsRequired = false, ColumnDescription = "工单详细描述")] public string? Description { get; private set; }
/// <summary> /// 业务状态枚举 /// </summary> /// <remarks> /// 数据库物理存储为 int 或 string 影子列。 /// </remarks> [BitzColumn(IsRequired = true, ColumnDescription = "工单状态")] public TicketStatus Status { get; private set; }
/// <summary> /// 工单地理位置复合值对象 /// </summary> /// <remarks> /// 编译期由 Roslyn 增量生成器自动扁平化为 Location_Province 与 Location_City 列。 /// </remarks> [BitzColumn(/* Prefix 规划中,当前由生成器默认列名约定 */)] public TicketLocation Location { get; private set; }
/// <summary> /// 动态扩展标签快照 /// </summary> /// <remarks> /// 以严格 JSON 格式落库持久化。 /// </remarks> [BitzColumn(IsJson = true, ColumnDescription = "动态标签集合快照")] public IReadOnlyList<string> Tags { get; private set; }
/// <summary> /// 纯内存计算属性 /// </summary> /// <remarks> /// 运行时即时求值,无需持久化到数据库物理列。 /// </remarks> [BitzColumn(Ignore = true)] public bool IsOverdue => Status == TicketStatus.Open && DateTimeOffset.UtcNow > CreateTime.AddDays(7);
/// <summary> /// 仅供 ORM 物化使用的无参构造函数 /// </summary> /// <remarks> /// 编译期 error: true 契约彻底杜绝业务代码误调用无参构造绕过工厂验证。 /// </remarks> [Obsolete("For ORM materialization only. Use Factory Methods.", error: true)] [EditorBrowsable(EditorBrowsableState.Never)] public Ticket() : base("0") { Title = "Materialized"; Status = TicketStatus.Open; Location = TicketLocation.Empty; Tags = []; }
/// <summary> /// 领域内聚私有构造函数 /// </summary> private Ticket( string id, string title, string? description, TicketLocation location, IReadOnlyList<string> tags, string tenantId) : base(id) { Title = title; Description = description; Status = TicketStatus.Open; Location = location; Tags = tags; TenantId = tenantId; }
/// <summary> /// 创建工单聚合根 /// </summary> /// <remarks> /// 执行领域不变量校验并初始化工单状态。 /// </remarks> public static Result<Ticket> Create( string? title, string? description, TicketLocation? location, IReadOnlyList<string>? tags, string? tenantId) { var normalizedTitle = title?.Trim(); if (string.IsNullOrWhiteSpace(normalizedTitle) || normalizedTitle.Length > TitleMaxLength) { return Result<Ticket>.Failure(TicketErrors.TitleInvalid); }
if (string.IsNullOrWhiteSpace(tenantId) || !TenancyDefaults.IsValid(tenantId)) { return Result<Ticket>.Failure(TicketErrors.TenantRequired); }
var ticket = new Ticket( "0", // 占位 Id,持久化管线在 Insert 时会自动由雪花算法覆写 normalizedTitle, description?.Trim(), location ?? TicketLocation.Empty, tags ?? [], tenantId);
return Result<Ticket>.Success(ticket); }
/// <summary> /// 解决工单 /// </summary> /// <remarks> /// 标记工单为已解决状态并记录解决时间。 /// </remarks> public Result Resolve() { if (Status == TicketStatus.Resolved) { return Result.Failure(TicketErrors.AlreadyResolved); }
Status = TicketStatus.Resolved; return Result.Success(); }}
/// <summary>/// 工单地理位置值对象/// </summary>public sealed record TicketLocation( [property: BitzColumn(Length = 64)] string Province, [property: BitzColumn(Length = 64)] string City){ public static TicketLocation Empty => new(string.Empty, string.Empty);}
public enum TicketStatus{ Open = 10, InProgress = 20, Resolved = 30, Closed = 40}持久化基础设施管道与架构红线
在 BitzOrcas.Modern 中,持久化基础设施是一条高度自律的工业级流水线。
架构红线与禁令清单
拦截器与自动审计注入流
当你在 Command Handler 中调用 await repository.SaveAsync(ticket) 时,底层拦截器(Interceptor)和 AOP 管道会自动横切完成以下工作:
- 雪花主键生成:
- 插入时检测到
Id == "0"或占位符,由IPersistenceIdGenerator(基于 Yitter Snowflake 算法)生成 15 位十进制以内的安全雪花 ID(纪元 2026-01-01,原生兼容前端 JavaScriptNumber.MAX_SAFE_INTEGER,防止精度丢失)。
- 插入时检测到
- 多租户与软删除数据隔离:
- EF Core 通过
ModelBuilder.HasQueryFilter注入全局过滤;SqlSugar 通过QueryFilter.AddTableFilter注入。查询时自动追加TenantId == @CurrentTenant AND IsDeleted == 0,物理杜绝跨租户越权。
- EF Core 通过
- 审计与时间戳自动化:
- EF Core 依赖
SaveChangesInterceptor;SqlSugar 依赖Aop.DataExecuting。 - 时间戳严格来自不可篡改的时钟端口
IAppClock.UtcNow。 - 操作人主体键(
CreateId/ModifyId)写入可信命名空间主体键(如user:1098234、application:client-99);显示名快照(CreateBy/ModifyBy)写入操作时的姓名,兼顾审计追溯与历史快照。
- EF Core 依赖
- 修改计数型乐观并发:
- 聚合的
Version字段采用严格修改计数语义:新增时为0,每次成功更新严格+1。更新时自动附加WHERE Version = @OldVersion,若版本不匹配则由 Adapter 捕获并转换为标准的Result.Failure(Error.Conflict)结果。
- 聚合的
总结与设计哲学
统一聚合根通过 “领域持久化合一 + 编译期代码生成 + 管道级自动横切” 的组合拳,消灭了 90% 的 CRUD 胶水代码,同时保障了 Native AOT 的零反射性能与双 ORM 的严格对齐。在编写业务代码时,请始终聚焦于业务不变量,将持久化细节交由底层内核处理。