Skip to content
bitzorcas
中EN

Reference

持久化构建块

统一聚合根、编译期 ORM 配置、Command/ReadModel/Query 端口、SqlSugar 与 EF Core 生产并行契约。

Last updated

BitzOrcas 的持久化目标不是让业务代码在三个 ORM 之间自由调用 API,而是让业务只依赖稳定端口,Provider 差异留在 Adapter 内。标准业务能力由 SqlSugar 与 EF Core 生产并行实现;Dapper 只用于登记过的只读 Query Store 场景。

关键路径图

下图展示了双 ORM 编译期生成体系:从领域聚合上的中立元数据,到 Source Generator 产出强类型配置并分发给 EF Core 与 SqlSugar 的执行闭环。

用例

Store 端口

SqlSugar 或 EF 适配器

数据库事务

提交后副作用

总体结构

Application use case
│
├─ Command Repository / Store ── 保存、恢复、删除聚合
├─ ReadModelStore ────────────── 标准详情、列表、过滤、排序、分页
└─ QueryStore ────────────────── join、timeline、group-by、报表
│
▼
Provider-neutral contracts
│
┌────────┴────────┐
▼ ▼
SqlSugar Adapter EF Core Adapter
│ │
└────────┬────────┘
▼
SQL Server + CAP Outbox
Dapper Adapter ── 只读复杂查询的显式例外,不注册写侧 UnitOfWork

Persistence:Provider 选择运行时 Provider,但这个选择不能泄漏到 Contracts、Domain、Application、Handler、Query DTO 或聚合。

默认写模型:统一聚合根

普通的一对一读写一致聚合只声明一次:

// ① 先看契约与控制流;校验、取消和类型化错误都要显式保留。
[BitzTable("SandboxNote", IsTenant = true, IsSoftDelete = true)]
public sealed class Note : TenantAggregateRoot<string>
{
// ② 值对象不直接持久化,生成配置通过桥接属性映射到现有 Name 列。
[BitzColumn(Ignore = true)]
public NoteTitle Title { get; private set; }
[BitzColumn(ColumnName = "Name", Length = 200, IsRequired = true)]
public string TitleName
{
get => Title.Value;
// ③ 回读数据库时仍经值对象工厂恢复领域表示。
private set => Title = NoteTitle.From(value);
}
}

默认完成态没有:

  • 与聚合一一对应的 NoteEntity.cs;
  • 只复制同名字段的双向 Mapper;
  • assembly 级 Mapping Specs;
  • 为了满足 DI 而存在的空 Query Translator。

[BitzTable]、[BitzColumn]、[BitzIndex] 等是 Provider 中立元数据。ORM Fluent Configuration Generator 在编译期读取 Roslyn Symbol,并生成:

  • EF Core model configuration;
  • SqlSugar 静态 mapping 与 schema metadata;
  • SmartEnum、值对象、JSON、精度、长度和索引配置;
  • 主键、审计、租户、软删和并发访问器;
  • 不受支持形状的编译诊断。

运行时不能反射读取这些特性来搭建映射。

什么时候允许独立 persistence model

真正不对称的存储形状可以例外,例如一个聚合写入多张表、需要不可变历史行,或采用专门报表模型。例外需要同时具备:

  1. 聚合形状与存储形状为何不同的证据;
  2. 模块局部的 mapping/restore 逻辑;
  3. 租户、子表同步和删除语义测试;
  4. SqlSugar/EF Core parity 状态;
  5. 例外的删除条件或长期保留理由。

不要因为“以前都是这样写”就创建 *Entity。

三种读写端口

Command Repository / Store

用于保存、按一致性需要恢复、删除聚合。事务由 pipeline 和 IUnitOfWork 管理,Handler 不直接提交。统一聚合路径可以让 Repository 直接使用同一类型,不需要 dummy mapper。

ReadModelStore

用于标准列表和详情。Query Shape Generator 编译字段、固定排序、过滤、分页和投影声明,Adapter 把它们下推到数据库:

Query input declaration
→ generated QueryDescriptor
→ FilterInput / SortRequest / PageRequest
→ provider executor
→ database projection
→ DTO

不要先分页加载聚合,再在内存中转 DTO。也不要接受用户传入任意列名作为排序表达式。

QueryStore

用于 Query Shape 不适合承载的业务读:timeline、inbox、history、join、exists、group-by、union 或报表源。接口描述业务问题,不暴露 IQueryable、DbContext、ISqlSugarClient、connection 或 SQL 字符串。

Dapper 只能作为这类只读端口的 Infrastructure 实现之一。用户输入必须参数化或先编译成固定 choice;代码库的架构门禁禁止在 C# 中新增手写 T-SQL 字符串。

SqlSugar 与 EF Core 的完成标准

两个 Provider 都是标准业务能力的生产目标:

状态含义
Production Parallel两侧实现存在,并通过同一组端口行为契约
Parity Blocker缺少一侧实现或共享行为证据,不能宣称生产完成
Explicit Exception明确不属于双 ORM 范围,已有 ADR/矩阵和独立测试策略

“可切换”至少需要证明:

  • 事务成功提交、显式失败回滚和异常回滚;
  • 租户隔离、软删除、审计字段和乐观并发;
  • 端口特有的保存、查找、分页或复杂查询语义;
  • Application/Domain 没有 Provider 类型泄漏;
  • 架构测试能发现单侧 Adapter 和单侧契约测试。

EF Core 项目当前显式设置 IsTrimmable=false,这是 Adapter 的 trim 隔离,不表示它是次等或“仅开发”实现。SqlSugar 维持 trim-clean 路径;两者的业务完成标准仍是同一行为契约。

租户、软删、审计与并发

生成的元数据与 Provider Adapter 协作实现横切语义:

  • ITenantEntity 默认按当前持久化上下文过滤;
  • ISoftDelete 默认隐藏已删除记录;
  • IAuditableEntity 由时钟和当前用户上下文填充;
  • IConcurrencyTracked.Version 用于乐观并发检测。

全局过滤不等于所有操作天然安全。按主键更新、批量删除、复杂 Query 和多表子记录仍要在端口契约中验证租户与并发语义。

事务、领域事件与 Outbox

事务行为包裹 Command Handler。聚合写入、需要原子发布的集成消息与 CAP Outbox 在 Adapter 约定内提交。

AggregateRepositoryBase 接收可选的 IDomainEventCollector。在 Save/Delete/SaveRange/DeleteRange 时执行写前守卫:若聚合是带待发事件的 IDomainEventSource,但收集器缺失(未注册)或未暴露 IDomainEventDispatchCapability,则抛错并指向 AddBitzOrcasDomainEventDispatchRuntime。写入成功后调用 Track(source)。AddBitzOrcasSqlSugarWithCap / AddBitzOrcasEfCoreWithCap 内部注册分发运行时;未接 CAP 而装配仓储的 Host 必须显式调用 AddBitzOrcasDomainEventDispatchRuntime。

Handler 的职责是返回 Result:

  • 业务失败返回失败结果,事务行为回滚;
  • 未预期异常向上抛出,事务行为回滚并由 Host 转成 Problem Details;
  • Handler 不自行调用 CommitAsync(),也不在保存成功后手写重复事件发布。

默认端口策略与不可用失败合同

每个持久化端口声明 PersistenceDefaultPolicy,决定未注册生产适配器时 DI 源生成器生成什么:

策略行为
Auto(默认)生成业务关闭默认实现,但仅对每个成员都返回 Result/Result<T>/Task<Result…>/ValueTask<Result…> 的接口(否则 BODI028)。
FailLoud生成抛错或返回 faulted 异步结果的代理。
Required不注册默认实现;端口进入编译期 Required Manifest,未解析则 Production/Staging 启动失败。

裸返回值端口(成员不返回 Result)必须显式选 FailLoud 或 Required——生成器拒绝猜测。策略由 [FailClosedPort]、[FailClosedPort<TService>]、[RegisterPersistenceAdapter<TService>] 与 [RegisterOrmAdapter<TService>] 携带。

FailLoud 端口抛携带稳定错误 System.PersistenceUnavailable 的 PersistenceUnavailableException。PersistenceUnavailableExceptionHandler(由 AddBitzOrcasProblemDetails 自动注册)将其映射为 RFC 9457 503,标题 “Service Unavailable” 并带 errorCode 扩展,绝不泄漏内部异常、连接串或堆栈。PersistenceCapability 标志(Database/Cap/Search/FileStorage)声明端口所需基础设施,ProductionAdapterReadinessGuard 在启动期解析每个 Required 端口,若解析为不可用代理则失败关闭。

编译期 ORM 选择

除运行时 Persistence:Provider 外,Consumer Host csproj 可用 MSBuild 属性在编译期收窄传递性双 ORM 闭包:

<PropertyGroup>
<BitzOrcasOrmProvider>SqlSugar</BitzOrcasOrmProvider>
<!-- 或 EfCore -->
</PropertyGroup>

DI 源生成器读取 build_property.bitzorcasormprovider 并从闭包中丢弃未选 Provider 的实现。三条阻断诊断守护:BODI020(聚合仓储需要已引用的 ORM Provider 包)、BODI021(BitzOrcasOrmProvider 值非 SqlSugar 或 EfCore)、BODI022(所选 Provider 的实现类型不可解析——加匹配的 BitzOrcas.Framework.Infrastructure Provider PackageReference)。这与运行时 Persistence:Provider 是不同机制;两者须保持一致。

Schema 与迁移

生成的 schema metadata 用于 Provider 配置和初始化,但生产 schema 演进仍需可审查、幂等、fail-closed 的迁移证据。迁移应先验证旧数据,再变更约束或回填字段;遇到无法安全转换的数据要停止,而不是猜测修复。

首次环境初始化、种子数据、版本升级和回滚分别有不同责任。不要把“开发环境 CodeFirst 能建表”等同于“生产升级已验证”。参见数据库迁移和升级指南。

配置示例

{
"ConnectionStrings": {
"Default": "Server=localhost;Database=BitzOrcas;..."
},
"Persistence": {
"Provider": "SqlSugar"
},
"RabbitMq": {
"Host": "localhost",
"Port": 5672
}
}

切换为 EfCore 只改变组合根选择的 Adapter。若某个端口仍是 Parity Blocker,系统与文档都不能把它描述为完整可切换。

选择清单

要改变聚合状态?
→ Command Repository / Store
要做标准详情、列表、过滤、排序、分页和 DTO 投影?
→ ReadModelStore + generated Query Shape
要做 join、timeline、group-by、history 或 report?
→ 专用 QueryStore,并明确双 Adapter 或例外状态
想新增 Entity + Mapper?
→ 先证明存储形状不对称;否则持久化统一聚合根

相关

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%