BitzOrcas.Domain 是最内层的共享构建块。它不注册 DI、不启动中间件,也不引用 ORM。业务模块使用这里的类型表达身份、状态边界、失败语义和领域事实。
实体与聚合层级
Entity<TId> ├─ Id ├─ CreateTime / ModifyTime / CreateBy / ModifyBy / ImpersonatorId └─ DomainEvents + Raise() + ClearDomainEvents() │ ▼AggregateRoot<TId> ├─ IsDeleted / DeleteTime / DeleteBy └─ Version ├─ TenantAggregateRoot<TId> + TenantId └─ PlatformAggregateRoot<TId>Entity<TId>
实体以具体类型和 Id 判断相等。基类已经实现 IAuditableEntity,并收集待分发领域事件。新对象使用占位标识时,持久化管线可以通过 AssignPersistenceId 回填最终标识。
不要在业务代码中随意设置审计属性。它们是持久化与调用者上下文共同维护的横切状态。
AggregateRoot<TId>
聚合根是一致性和事务边界。它在实体基础上实现:
ISoftDelete:IsDeleted、DeleteTime、DeleteBy;IConcurrencyTracked:乐观并发Version。
外部代码通过聚合根行为改变内部状态,不应直接持有并修改聚合内部实体。
租户与平台聚合
租户业务数据继承 TenantAggregateRoot<TId>,得到字符串 TenantId;平台级数据继承 PlatformAggregateRoot<TId>。选择哪一种是所有权决定,不是为了少写一个属性。
请求中的租户值不可信。聚合创建时应使用当前用户、系统作业或经验证的租户作用域。
Result 与 Error
预期的业务失败不抛异常,而是返回 Result 或 Result<T>:
// ① 先看契约与控制流;校验、取消和类型化错误都要显式保留。public static Result<Note> Create(string title, string tenantId){ // ② 聚合工厂保护不变量,并用稳定 Error 说明预期失败。 if (string.IsNullOrWhiteSpace(title)) { return Result<Note>.Failure(SandboxErrors.NoteTitleInvalid); }
return Result<Note>.Success(new Note(title, tenantId));}Error 包含稳定 Code、开发诊断 Description 和 ErrorType。当前错误类型及默认 HTTP 语义为:
ErrorType | 典型含义 | HTTP |
|---|---|---|
Validation | 格式、必填、长度、枚举范围 | 400 |
NotFound | 资源不存在 | 404 |
Conflict | 并发、幂等或状态冲突 | 409 |
Forbidden | 已认证但无权限 | 403 |
Unauthorized | 未认证 | 401 |
Failure | 用例级业务规则不满足 | 422 |
Unexpected | 未预期系统失败的兜底 | 500 |
不存在 Infrastructure 错误类型。数据库不可用等未预期故障进入异常与 Problem Details 通道,除非某个外部调用本身有明确、可恢复的业务失败契约。
Description 不是面向用户的本地化文案。API 或客户端通过稳定错误码映射显示文本。
领域事件
领域事件是聚合已经发生的事实:
// ① 先看契约与控制流;校验、取消和类型化错误都要显式保留。[IntegrationTopic("note.created")]public sealed record NoteCreatedDomainEvent( string TenantId, string Title) : DomainEvent;聚合调用 Raise(event) 收集事件,Application 使用 IDomainEventCollector 跟踪聚合,DomainEventDispatchPipelineBehavior 按事务约定分发。带 [IntegrationTopic] 的事件进入集成发布路径。
注意边界:
- 事件使用过去时命名;
- 只携带消费者需要的稳定事实,不携带 ORM 实体;
- 聚合构造或状态变化负责抛出事件,Handler 不重复构造同一事实;
- 从数据库 Restore 聚合时不能重新抛出“已创建”事件。
值对象与 SmartEnum
ValueObject 为无身份的概念提供按值相等语义,例如 EmailAddress。模块可以定义自己的 NoteTitle、金额或范围类型,把校验与规范化集中起来。
SmartEnum<TEnum,TValue> 用于需要稳定名称和值、又需要行为的有限集合。持久化时优先通过显式标量属性和编译期生成的 converter 存储,读取未知值应按该业务契约 fail closed。
W0 列表查询契约新增四类共享筛选值对象:
| 类型 | 当前语义 | 非法输入 |
|---|---|---|
DateRange | 可单侧无界的时间闭区间;纯日期上界补齐到当日最后一个 tick | 下界晚于上界返回 DateRange.Invalid |
AmountRange | 使用 decimal 的金额闭区间 | 下限大于上限返回 NumberRange.Invalid |
NumberRange<TNumber> | 数量、评分、百分比等可比较值的闭区间 | 下限大于上限返回 NumberRange.Invalid |
EnumFilterSet<TEnum> | 去重、排序并冻结的枚举集合;相等性忽略输入顺序 | null 按空集合处理 |
这些类型的创建入口保护不变量,调用方不能通过公开 setter 构造非法状态。DateRange 的 JSON 转换器也重新调用工厂;数据库过滤使用排他上界,而展示和 Contains 使用闭区间。完整的 HTTP 信封、分页与 QueryShape 展开规则见列表查询与导出范围契约。
平台硬边界
GlobalPlatformConstants 保存不可按租户配置、且被多个模块共同使用的稳定上限。在线列表默认 20、最大 1000,仍由 PagingLimits 定义;GlobalPlatformConstants.Pagination 只提供别名。后台 worker 使用独立的 Batch.PageSize=2000,不能拿它放宽 HTTP 请求。
通用文本边界为:稳定标识 128、短描述 1000、备注与审计原因 2000。远程选项 50、Workflow QueryStore 200、SCIM 200 属于各自协议或引擎,不合并进全局在线分页值。
时钟
IAppClock 是 Domain 与 Application 读取“现在”的统一抽象。默认 SystemClock 从 DI 注入的 TimeProvider 取时;业务代码不得直接读取 DateTime.Now、DateTime.UtcNow、DateTimeOffset.UtcNow 或 TimeProvider.System。聚合行为优先由调用者传入时刻,确需持续读时再注入领域服务。
| API | 类型 | 当前语义 | 适用场景 |
|---|---|---|---|
UtcNow | DateTimeOffset | 注入时间源的 UTC 时刻 | 审计、令牌、过期、领域事件与安全窗口 |
Instant | NodaTime Instant | 由 UtcNow 转换的绝对时间点 | 跨时区日程、财务日期与需要 NodaTime 的模型 |
Now | DateTime | UTC,Kind=Utc | 兼容仍使用 DateTime 的旧边界 |
ToStorage(value) | DateTime | 将绝对时刻归一化为 UTC 存储值 | 旧仓储写边界 |
FromStorage(value) | DateTimeOffset | 把存储值按 UTC 还原;Unspecified 历史值也按 UTC 解释 | 旧仓储读边界 |
Clock:Kind 默认为 Utc。Local、Unspecified 与 Clock:TimeZoneId 目前仅保留配置兼容性,ClockNormalize 仍统一按 UTC 读写;不能通过设置 Kind=Local 承诺本地墙钟存储。展示时区转换应留在 API/前端展示边界,不进入审计与持久化事实。
组合根注册 TimeProvider.System;测试可替换为可推进的 TimeProvider。SystemClock 会持续读取该时间源,FixedClock(TimeProvider) 则只在构造时捕获一次。DateTimeExtensions 的 IsPast、IsFuture 与 Elapsed 也有 TimeProvider 重载,确定性测试应使用这些入口。
// 业务用例从显式时钟取得绝对时间,再传给聚合行为。var occurredAt = clock.UtcNow;var result = aggregate.Approve(occurredAt, currentUser.UserId);
// 测试通过组合根替换时间源,不修改生产代码。services.AddSingleton<TimeProvider>(new MutableTimeProvider(fixedUtcNow));使用边界
- Domain 可以引用这些原语和模块自身 Contracts。
- Domain 不引用 Mediator Handler、
HttpContext、ORM、CAP、Redis 或配置系统。 [BitzTable]/[BitzColumn]是 Provider 中立的声明元数据,由编译期生成器读取;Domain 不调用 Provider API。- 不要把跨模块共享业务规则塞进 Core。共享的是稳定技术语义,模块业务仍由模块所有。
设计审查清单
- 聚合根是否明确了一致性边界,而不是把一张表机械包装成聚合?
- 所有预期失败是否返回稳定
Error.Code,并与未预期异常分流? - 创建和状态迁移是否通过方法保护不变量,避免公开 setter 绕过规则?
- 租户数据是否继承正确基类,并从可信上下文取得
TenantId? - 领域事件是否描述已经发生的事实,并只携带稳定载荷?
- Restore 路径是否避免重复抛出创建事件或覆盖审计字段?
- 时间、当前用户与随机性是否由调用者或显式端口提供?
- 值对象与 SmartEnum 的未知值、序列化和持久化行为是否有测试?
- 在线分页、后台批量和协议例外是否使用各自具名边界,而不是复用一个魔法数?
源码与验证入口
核心类型位于 src/Framework/BitzOrcas.Domain;时钟实现位于 src/Framework/BitzOrcas.Infrastructure/Clocking;领域事件收集与管线位于 src/Framework/BitzOrcas.Application。时钟回归由 ApplicationClockTests 覆盖;查询值对象由 PaginationParamsTests、DateRangeTests 和 RangeValueObjectTests 覆盖。新增或修改核心原语后,应同时运行 Domain 单元测试、公开 API/架构测试,以及受影响模块的持久化契约测试。