Skip to content
bitzorcas
中EN

Guide

Sandbox 黄金样例全景拆解

深入剖析模板内置 src/Modules/Sandbox 黄金参考样例:三层物理工程划分、CQRS 读写分离、适配器中立聚合根与窄端口读模型。

Last updated

Sandbox 黄金样例全景拆解 (Sandbox Golden Sample)

在 BitzOrcas.Modern 架构体系中,src/Modules/Sandbox 是官方维护的业务模块黄金样例(Golden Use Case)。它不依赖任何外部业务系统,以最严苛的工程标准完整呈现了一个符合 ADR 0203 物理隔离准则的独立业务模块。

当需要在 src/Modules/ 目录下开发新的业务域(如 Litigation 诉讼域、Contracts 合同域)时,Sandbox 是唯一官方推荐的标准对照样例。


1. 三层工程物理拓扑

Sandbox 模块由 3 个物理 csproj 组成,彼此遵循单向依赖与防腐穿透红线:

框架底座 (src/Framework)src/Modules/Sandbox/ (业务模块空间)宿主层 (src/Hosts/BitzOrcas.Api)装配装配依赖实现端口基础原语

BitzOrcas.Api

BitzOrcas.Sandbox.Contracts
(契约层:领域聚合、DTO、错误字典、接口端口)

BitzOrcas.Sandbox.Application
(应用层:CQRS 命令/查询 Handler、业务验证规则)

BitzOrcas.Sandbox.Infrastructure
(基础设施层:只读 Store、QueryShape 适配器)

BitzOrcas.Domain / Application / Infrastructure

三层职责划分硬性约束

工程目录允许包含的内容严禁包含的内容
BitzOrcas.Sandbox.Contracts领域实体聚合根(Domain/Note.cs)、外部 DTO(NoteDto.cs)、强类型只读端口接口(INoteReadStore.cs)、模块错误字典(SandboxErrors.cs)、权限常量(SandboxPermissions.cs)严禁包含具体 ORM 命名空间与包依赖、严禁包含 HTTP 上下文、严禁反向依赖 Application 或 Infrastructure。
BitzOrcas.Sandbox.ApplicationCQRS 命令与 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 严格执行适配器中立的元数据物理法则:

  1. 统一聚合根基类:继承 TenantAggregateRoot<string>,租户与实体主键统一采用分布式雪花算法 string 标识,自动内置多租户隔离、审计追踪与软删除。
  2. 规范表名与元数据:使用适配器中立的编译期元数据特性 [BitzTable("SandboxNote", ...)](来自 BitzOrcas.Persistence.Metadata),数据表直接以业务名词命名为 SandboxNote,禁止任何带有 T_ 前缀的魔术命名。
  3. 彻底封闭私有状态:业务属性统一采用 private set,禁止外部代码绕过领域不变量直接篡改字段。
  4. 工厂方法自闭环:通过 Note.Create 静态工厂返回 Result<Note>,在领域聚合内部完成输入修整与不变量校验。
  5. 防止绕过工厂的 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 在读路径执行严格的窄模型端口与下推机制:

  1. Contracts 暴露读模型窄端口:INoteReadStore 仅返回不可变 NoteDto 与 PagedResult<NoteDto>,绝不暴露领域实体或 IQueryable。
  2. Infrastructure 承载适配器实现:通过 [RegisterPersistenceAdapter<INoteReadStore>] 自动向 DI 容器注入适配器。
  3. 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. 相关架构决策与推荐阅读

100%

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