Shared 的价值不在于“少写几遍代码”,而在于给所有模块提供少量、稳定、没有业务所有者的基础语义。错误地把业务 DTO、实体、仓储接口或通用 Helper 放进 Shared,会把显式模块依赖变成全局隐性耦合。
归属判断
“目前有两个调用方”不是共享理由。类型若可能由某个业务模块独立演进,就应该由该模块拥有;调用方通过 Contracts 依赖它。真正的基础抽象还必须保持轻依赖,不能反向引用 Platform 或 Modules。
| 构建块 | 真实职责 | 典型误用 |
|---|---|---|
Result / Error | 表达预期业务失败与稳定错误分类 | 用异常表示校验失败 |
IAppClock | 统一当前时刻和持久化时间转换 | 在 Handler 中调用 DateTime.Now |
ICurrentUser | 提供认证后可信身份快照 | 相信请求体中的 TenantId |
ITenantContext | 当前租户访问器的只读兼容投影 | 保存第二份可写租户状态 |
| Seed framework | 有序、可审计地执行 ORM 中立种子步骤 | 启动时无条件插入演示账号 |
Result 与 Error
Domain 和 Application 的预期失败使用 Result。Error.Code 是稳定的定位与 i18n 键,建议遵循 {Module}.{Scenario}.{Reason};Description 只用于开发诊断,不能直接作为终端用户文案。ErrorType 决定 Web 边界的 HTTP 分类。
public static class BillingErrors{ public static readonly Error AmountInvalid = Error.Validation("Billing.Refund.AmountInvalid", "退款金额必须大于零。");
public static readonly Error ExceedsPaidAmount = Error.Failure("Billing.Refund.ExceedsPaidAmount", "退款金额不能超过已支付金额。");}
public static Result<Money> CreateRefund(decimal amount, decimal paidAmount){ if (amount <= 0) { // 稳定错误码供 API、客户端和审计关联;描述不是用户翻译文本。 return Result.Failure<Money>(BillingErrors.AmountInvalid); }
if (amount > paidAmount) { // 可预期的规则不满足用 Failure,不抛业务异常。 return Result.Failure<Money>(BillingErrors.ExceedsPaidAmount); }
return Result.Success(new Money(amount, "CNY"));}Validation、NotFound、Conflict、Forbidden、Unauthorized、Failure 和 Unexpected 是不同语义,不要为了得到某个状态码而错选类型。异常仍适用于程序错误、损坏的不变量和无法恢复的基础设施故障,并由全局异常处理器统一收口。
时间、调用者与租户
需要“现在”的领域或应用逻辑注入 IAppClock。UtcNow 用于绝对时刻,Instant 用于 NodaTime 场景,Now 遵循统一 ClockOptions.Kind;仓储边界通过 ToStorage / FromStorage 对称转换。业务代码不自行推断数据库时区。
ICurrentUser.User 是认证基础设施构造的 CurrentUser。授权和数据范围使用 EffectiveUserId,审计真实操作者使用 ActorUserId;委托场景下两者可能不同。租户标识来自可信上下文,不由 DTO 覆盖。
public static class ApprovalErrors{ public static readonly Error CallerRequired = Error.Unauthorized("Approval.Caller.Required", "需要已认证调用者。");}
public sealed class ApproveHandler(ICurrentUser currentUser, IAppClock clock){ public Result<Approval> Handle(ApproveCommand command) { var caller = currentUser.User; if (!caller.IsAuthenticated || caller.EffectiveUserId is null) { // 身份缺失是类型化失败;不要直接读取 HttpContext 或请求头。 return Result.Failure<Approval>(ApprovalErrors.CallerRequired); }
// 有效用户用于业务动作,真实操作者另行写入审计字段。 return Approval.Create( command.SubjectId, caller.EffectiveUserId.Value, caller.ActorUserId, clock.UtcNow); }}ITenantContext 只是 ICurrentTenantAccessor.Current 的只读兼容门面,只有 TenantId 和可选 OfficeId。新的可写作用域应通过 ICurrentTenantAccessor.BeginScope 建立,避免异步流中残留或出现两份互相矛盾的当前租户。
种子框架
ISeedRunner 按 Order、再按 SeedId 排序串行执行。重复 SeedId、重复 Order 和不存在的依赖会 fail-loud。Demo 步骤在 Production 与 Staging 自动跳过;取消会继续抛出,普通异常写入 SeedRunReport,是否停止由 StopOnError 决定。
public sealed class CountrySeedStep(ICountrySeedStore store) : ISeedStep{ public int Order => 120; public string SeedId => "sys_country"; public int Version => 2; public string[] DependsOn => [];
public async Task ExecuteAsync(string environment, CancellationToken ct) { // 用稳定业务代码 Upsert;不能依赖数据库自增主键判断是否已执行。 await store.UpsertAsync("CN", "中国", ct); await store.UpsertAsync("SG", "新加坡", ct); // 重复运行必须收敛到相同状态,不能产生重复记录。 }}BitzOrcas:Seed 可配置 Enabled、AutoRunOnStartup、StopOnError、OverrideRootPath、CsvDelimiter 与 SkipSeedIds。AutoRunOnStartup 默认关闭。生产种子不得包含固定密码、演示账号或不可轮换 Token;CSV 磁盘覆盖是运维能力,也意味着发布流程必须校验来源与完整性。
值对象、脱敏与审计端口
Email、金额、标识等值对象的格式和不变量属于 Domain,并以 Result 创建。跨模块脱敏使用统一 Masker;不要在每个 Handler 复制正则。审计能力以 Application 端口表达,由 Host 选择生产适配器,使领域模型既不依赖 HTTP,也不依赖日志或数据库实现。
Shared 不是“所有横切逻辑”的收纳箱。缓存、锁、事件和审计即使被多模块使用,也有独立行为、配置和失败策略,应维持各自构建块边界。
测试与演进门禁
基础构建块的改动影响面大,应以公开 API 测试、依赖方向测试和跨模块契约测试保护。新增类型进入 Shared 前至少回答:谁负责版本兼容、是否引入业务语言、是否隐藏外部状态、是否能由更具体的 owner 契约替代。
测试还应覆盖 Result 成功/失败不变量、委托身份的 Actor/Effective 区分、时钟转换对称性、种子重复与依赖诊断,以及 Demo 环境门禁。任何破坏性变更都应先迁移调用方,再删除旧抽象。
源码定位
src/Framework/BitzOrcas.Domain/Results/src/Framework/BitzOrcas.Domain/Abstractions/IAppClock.cssrc/Framework/BitzOrcas.Application/Abstractions/Users/src/Framework/BitzOrcas.Infrastructure/Seeders/