Sandbox 黄金样例全景拆解 (Sandbox Golden Sample)
在 BitzOrcas.Modern 架构体系中,src/Modules/Sandbox 是官方维护的业务模块黄金样例(Golden Use Case)。它不依赖任何外部业务系统,以最严苛的工程标准完整呈现了一个符合 ADR 0203 物理隔离准则的独立业务模块。
当需要在 src/Modules/ 目录下开发新的业务域(如 Litigation 诉讼域、Contracts 合同域)时,Sandbox 是唯一官方推荐的标准对照样例。
1. 三层工程物理拓扑
Sandbox 模块由 3 个物理 csproj 组成,彼此遵循单向依赖与防腐穿透红线:
三层职责划分硬性约束
| 工程目录 | 允许包含的内容 | 严禁包含的内容 |
|---|---|---|
BitzOrcas.Sandbox.Contracts | 领域实体聚合根(Domain/Note.cs)、外部 DTO(NoteDto.cs)、强类型只读端口接口(INoteReadStore.cs)、模块错误字典(SandboxErrors.cs)、权限常量(SandboxPermissions.cs) | 严禁包含具体 ORM 命名空间与包依赖、严禁包含 HTTP 上下文、严禁反向依赖 Application 或 Infrastructure。 |
BitzOrcas.Sandbox.Application | CQRS 命令与 Handler(Commands/Notes/*)、CQRS 查询与 Handler(Queries/Notes/*)、FluentValidation 验证规则、应用装配标记(SandboxApplicationAssembly.cs) | 严禁直接拼装底层 SQL;严禁包含跨模块的物理仓储实现;写操作仅依赖 ORM 中立的窄仓储端口。 |
BitzOrcas.Sandbox.Infrastructure | 契约接口的具体实现(NoteReadStore.cs)、IQueryShapeExecutorFactory 数据库投影下推、第三方客户端适配 | 严禁越过 Contracts 定义私有业务规则;适配器必须通过强类型注解注册。 |
2. 领域聚合根与元数据隔离:Note.cs
在传统工程实践中,开发人员常常直接在实体上打满特定 ORM 的特性标签(如 [SugarTable("T_xxx")] 或 [Table]),将数据表命名硬编码为带 T_ 前缀的魔术字符串,并开放公共的 public set,导致领域实体退化为贫血模型。
BitzOrcas.Modern 严格执行适配器中立的元数据物理法则:
- 统一聚合根基类:继承
TenantAggregateRoot<string>,租户与实体主键统一采用分布式雪花算法string标识,自动内置多租户隔离、审计追踪与软删除。 - 规范表名与元数据:使用适配器中立的编译期元数据特性
[BitzTable("SandboxNote", ...)](来自BitzOrcas.Persistence.Metadata),数据表直接以业务名词命名为SandboxNote,禁止任何带有T_前缀的魔术命名。 - 彻底封闭私有状态:业务属性统一采用
private set,禁止外部代码绕过领域不变量直接篡改字段。 - 工厂方法自闭环:通过
Note.Create静态工厂返回Result<Note>,在领域聚合内部完成输入修整与不变量校验。 - 防止绕过工厂的 ORM 防线:无参构造函数打标
[Obsolete("For ORM materialization only. Use Create.", error: true)]与[EditorBrowsable(EditorBrowsableState.Never)],编译期直接拦截任何外部违规实例化。
using System.ComponentModel;using BitzOrcas.Domain.Contracts;using BitzOrcas.Domain.Entities;using BitzOrcas.Domain.Results;using BitzOrcas.Domain.Tenancy;using BitzOrcas.Persistence.Metadata;using BitzOrcas.Sandbox.Contracts;
namespace BitzOrcas.Sandbox.Domain;
/// <summary>/// Note owner-private 聚合根/// </summary>/// <remarks>/// 演示聚合不变量与跨切契约(ITenantEntity/ISoftDelete/IAuditableEntity/IConcurrencyTracked)。/// 持久化标识与 TenantId 均使用 string;审计、软删除和并发字段由租户聚合基类提供。/// 本类只声明适配器中立的编译期持久化元数据;持久化层直接物化此聚合,雪花 Id 由写入管线在插入时生成。/// </remarks>[BitzTable("SandboxNote", IsTenant = true, IsSoftDelete = true, Description = "Sandbox 笔记")]public sealed class Note : TenantAggregateRoot<string>{ /// <summary> /// 标题最大长度 /// </summary> public const int TitleMaxLength = 200;
/// <summary> /// 标题 /// </summary> [BitzColumn(ColumnName = "Name", Length = TitleMaxLength, IsRequired = true)] public string Title { get; private set; }
/// <summary> /// 创建仅供 ORM 持久化物化使用的空笔记 /// </summary> /// <remarks> /// 该构造函数建立可被持久化管线覆盖的有效占位状态。ORM 物化需要 public 无参构造; /// ObsoleteAttribute 的 error 契约会在编译期阻止业务代码绕过 Create, /// EditorBrowsableAttribute 则把该入口从 IDE 常规候选中隐藏。 /// </remarks> [Obsolete("For ORM materialization only. Use Create.", error: true)] [EditorBrowsable(EditorBrowsableState.Never)] public Note() : base("0") { Title = "Materialized"; }
/// <summary> /// 私有构造,由工厂方法调用 /// </summary> private Note(string id, string title, string tenantId) : base(id) { Title = title; TenantId = tenantId; }
/// <summary> /// 创建 Note 聚合 /// </summary> /// <param name="title">标题原始输入。</param> /// <param name="tenantId">所属租户,须来自认证上下文。</param> /// <returns>校验通过返回新建聚合;标题或租户边界非法时返回对应的稳定错误。</returns> public static Result<Note> Create(string? title, string? tenantId) { var normalizedTitle = title?.Trim(); if (string.IsNullOrWhiteSpace(normalizedTitle) || normalizedTitle.Length > TitleMaxLength) { return Result<Note>.Failure(SandboxErrors.NoteTitleInvalid); }
if (string.IsNullOrWhiteSpace(tenantId) || !TenancyDefaults.IsValid(tenantId)) { return Result<Note>.Failure(SandboxErrors.NoteTenantInvalid); }
var note = new Note("0", normalizedTitle, tenantId); return Result<Note>.Success(note); }}3. 强类型错误字典:SandboxErrors.cs
BitzOrcas 严禁在业务逻辑中抛出业务异常或硬编码魔术字符串。所有业务失败代码必须在模块契约层集中静态声明,明确区分 Validation、NotFound、Unauthorized 与 Failure:
using BitzOrcas.Domain.Results;
namespace BitzOrcas.Sandbox.Contracts;
/// <summary>/// Sandbox 模块稳定的强类型错误契约/// </summary>public static class SandboxErrors{ /// <summary> /// 笔记标题非法(空或超过 200 字符) /// </summary> public static readonly Error NoteTitleInvalid = Error.Validation("Sandbox.Note.TitleInvalid");
/// <summary> /// 笔记租户标识非法 /// </summary> public static readonly Error NoteTenantInvalid = Error.Validation("Sandbox.Note.TenantInvalid");
/// <summary> /// 当前请求没有可信租户上下文 /// </summary> public static readonly Error TenantRequired = Error.Unauthorized("Sandbox.Tenant.Required");
/// <summary> /// 笔记不存在 /// </summary> public static readonly Error NoteNotFound = Error.NotFound("Sandbox.Note.NotFound");
/// <summary> /// 列表页码非法(须从 1 开始) /// </summary> public static readonly Error NoteInvalidPage = Error.Validation("Sandbox.Note.InvalidPage");
/// <summary> /// 列表页大小非法 /// </summary> public static readonly Error NoteInvalidPageSize = Error.Validation("Sandbox.Note.InvalidPageSize");
/// <summary> /// Note 列表搜索文本超过公开查询上限 /// </summary> public static readonly Error NoteSearchTextInvalid = Error.Validation("Sandbox.Note.SearchTextInvalid");
/// <summary> /// Note 列表查询无法由当前持久化适配器完成 /// </summary> public static readonly Error NoteQueryFailed = Error.Failure("Sandbox.Note.QueryFailed");}4. CQRS 写路径单文件垂直切片:CreateNoteCommand.cs
写路径严格遵循单文件垂直切片(Vertical Slice Architecture):命令契约、路由源生成器特性、MCP 工具元数据打标、权限资源声明与处理器紧凑收敛在一个文件中。
Handler 绝不直接依赖数据库连接或宽仓储,仅依赖 ORM 中立的窄命令端口 ICommandRepository<Note, string>:
using BitzOrcas.Application.Abstractions.Tenancy;using BitzOrcas.Application.Authorization;using BitzOrcas.Domain.Abstractions;using BitzOrcas.Domain.Results;using BitzOrcas.Endpoint.Attributes;using BitzOrcas.Mcp.Attributes;using BitzOrcas.Sandbox.Contracts;using BitzOrcas.Sandbox.Domain;using Mediator;
namespace BitzOrcas.Sandbox.Application.Commands.Notes;
/// <summary>/// 创建 Note 命令/// </summary>[GenerateEndpoint(HttpRoute.Post, "/api/notes/", Tag = "Notes")][GenerateMcpTool("CreateNote", "创建一个新的笔记,需要提供标题和内容")]public sealed record CreateNoteCommand(string Title) : ICommand<Result<string>>, IAuthorizedRequest{ /// <summary> /// 受保护资源:sandbox 模块 note 资源 /// </summary> public ResourceDescriptor Resource { get; } = new( SandboxPermissions.Module, SandboxPermissions.NoteResource);
/// <summary> /// 授权动作:创建 /// </summary> public AuthorizationAction Action { get; } = AuthorizationAction.Create;}
/// <summary>/// 创建 Note 的应用编排处理器/// </summary>public sealed class CreateNoteCommandHandler( ICommandRepository<Note, string> repository, ICurrentTenant currentTenant) : ICommandHandler<CreateNoteCommand, Result<string>>{ /// <summary> /// 校验并创建 Note,在当前事务中保存聚合 /// </summary> public async ValueTask<Result<string>> Handle( CreateNoteCommand request, CancellationToken cancellationToken) { var tenant = currentTenant.Tenant; if (!tenant.IsAvailable) { return Result<string>.Failure(SandboxErrors.TenantRequired); }
// 委托聚合根内部工厂执行不变量检查 var noteResult = Note.Create(request.Title, tenant.EffectiveTenantId); if (noteResult.IsFailure) { return Result<string>.Failure(noteResult.Error); }
var note = noteResult.GetValueOrThrow(); var saveResult = await repository.SaveAsync(note, cancellationToken); return saveResult.IsFailure ? Result<string>.Failure(saveResult.Error) : Result<string>.Success(note.Id); }}5. CQRS 读路径窄端口与持久化适配器
为防止宽仓储的查询接口泛滥或向 API 泄露原始 ORM 实体,BitzOrcas 在读路径执行严格的窄模型端口与下推机制:
- Contracts 暴露读模型窄端口:
INoteReadStore仅返回不可变NoteDto与PagedResult<NoteDto>,绝不暴露领域实体或IQueryable。 - Infrastructure 承载适配器实现:通过
[RegisterPersistenceAdapter<INoteReadStore>]自动向 DI 容器注入适配器。 - QueryShape 查询执行器:列表查询通过
IQueryShapeExecutorFactory下推给当前启用的持久化引擎,按租户上下文自动安全过滤。
契约层窄端口定义 (INoteReadStore.cs)
using BitzOrcas.Domain.Abstractions.Queries;using BitzOrcas.Domain.Results;
namespace BitzOrcas.Sandbox.Contracts;
/// <summary>/// Note 读模型窄端口/// </summary>public interface INoteReadStore{ /// <summary> /// 按标识读取 Note 读模型 /// </summary> Task<Result<NoteDto>> GetByIdAsync(string id, CancellationToken cancellationToken = default);
/// <summary> /// 按组合查询列出当前租户 Note 读模型 /// </summary> Task<Result<PagedResult<NoteDto>>> SearchAsync( NoteListFields fields, PaginationParams paging, SortRequest? sort, CancellationToken cancellationToken = default);}基础设施层实现 (NoteReadStore.cs)
using BitzOrcas.Application.Abstractions.Tenancy;using BitzOrcas.DI.Attributes;using BitzOrcas.Domain.Abstractions;using BitzOrcas.Domain.Abstractions.Queries;using BitzOrcas.Domain.Results;using BitzOrcas.Infrastructure.Queries;using BitzOrcas.Sandbox.Contracts;using BitzOrcas.Sandbox.Domain;using Microsoft.Extensions.Logging;
namespace BitzOrcas.Sandbox.Infrastructure;
/// <summary>/// Note 读模型持久化适配器/// </summary>[RegisterPersistenceAdapter<INoteReadStore>]public sealed class NoteReadStore( ICommandRepository<Note, string> notes, IQueryShapeExecutorFactory executorFactory, ICurrentTenant currentTenant, ILogger<NoteReadStore> logger) : INoteReadStore{ public async Task<Result<NoteDto>> GetByIdAsync(string id, CancellationToken cancellationToken = default) { var noteResult = await notes.FindAsync(id, cancellationToken).ConfigureAwait(false); if (noteResult.IsFailure) { return noteResult.Error.Type == ErrorType.NotFound ? Result.Failure<NoteDto>(SandboxErrors.NoteNotFound) : Result.Failure<NoteDto>(noteResult.Error); }
var note = noteResult.GetValueOrThrow(); return Result.Success(new NoteDto(note.Id, note.Title)); }
public async Task<Result<PagedResult<NoteDto>>> SearchAsync( NoteListFields fields, PaginationParams paging, SortRequest? sort, CancellationToken cancellationToken = default) { var tenant = currentTenant.Tenant; if (!tenant.IsAvailable) { return Result.Failure<PagedResult<NoteDto>>(SandboxErrors.TenantRequired); }
try { var input = NoteListInput.From(fields, paging); return await input.ExecutePageAsync( executorFactory, static row => new NoteDto(row.NoteId, row.Title), sorts: sort.WithTieBreaker(nameof(NoteListInput.NoteId)), cancellationToken: cancellationToken) .ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception exception) when (exception is not OutOfMemoryException) { logger.LogError( "Sandbox Note query failed with {ExceptionType}.", exception.GetType().Name); return Result.Failure<PagedResult<NoteDto>>(SandboxErrors.NoteQueryFailed); } }}6. 相关架构决策与推荐阅读
- 模块生命周期:BitzOrcas.Modularity 模块生命周期与装配
- Profile 发行:Profiles 多租户定制与元包发行机制
- 决策溯源:ADR 0203:模块物理目录结构与防腐隔离