在 BitzOrcas.Modern 的每个模块内部,垂直切片架构(VSA)决定了组织方式。一个特性——创建备忘录、获取问候、发送聊天消息——生活在一个特性文件夹中:端点、处理器和验证规则放在一起。没有分层往返。没有 Domain → Application → Infrastructure → API 的跳转。一个特性,一个文件夹,一个 PR。
切片结构
一个切片分布在模块的各个项目中。CreateNote Sandbox 特性看起来像这样:
BitzOrcas.Application/Sandbox/Notes/├── CreateNoteCommand.cs ← ICommand<Note>(record)├── CreateNoteCommandHandler.cs ← ICommandHandler<CreateNoteCommand, Note>└── GetNoteByIdQuery.cs ← IQuery<Note?>(record)
BitzOrcas.Application/Sandbox/Notes/(处理器内联或分离)├── CreateNoteCommandHandler.cs ← 处理器实现└── GetNoteByIdQueryHandler.cs ← 处理器实现
BitzOrcas.Api/Endpoints/Sandbox/├── NoteEndpoints.cs ← Minimal API 端点映射完整切片 — CreateNote
学习 VSA 最干净的方式是端到端阅读一个切片。以下是 Sandbox 模块的 CreateNote:
public sealed record CreateNoteCommand(string Title, string Content) : ICommand<Result<Note>>;public sealed class CreateNoteCommandHandler : ICommandHandler<CreateNoteCommand, Result<Note>>{ private readonly ISandboxDataPort _dataPort;
public CreateNoteCommandHandler(ISandboxDataPort dataPort) => _dataPort = dataPort;
public async ValueTask<Result<Note>> Handle( CreateNoteCommand command, CancellationToken cancellationToken) { var note = Note.Create(command.Title, command.Content); await _dataPort.AddAsync(note, cancellationToken).ConfigureAwait(false); return Result<Note>.Success(note); }}public static class NoteEndpoints{ internal static RouteHandlerBuilder MapNoteEndpoints(this IEndpointRouteBuilder endpoints) { var group = endpoints.MapGroup("/api/v1/sandbox/notes");
group.MapPost("/", async ( CreateNoteCommand command, IMediator mediator, CancellationToken cancellationToken) => { var result = await mediator.Send(command, cancellationToken); return result.IsFailure ? TypedResults.BadRequest(result.Error) : TypedResults.Created($"/api/v1/sandbox/notes/{result.Value.Id}", result.Value); }) .WithName("CreateNote") .WithSummary("Create a sandbox note");
// GET by ID, GET all, etc.
return group; }}这就是整个特性。小文件。一个切片。一个 PR。
Result<T> — 业务失败绝不抛异常
BitzOrcas 强制使用 BitzOrcas.Domain 的 Result<T> 模式。业务失败返回 Result.Failure(Error),永不抛异常:
// 成功路径return Result<Note>.Success(note);
// 业务失败路径return Result<Note>.Failure(new Error( ErrorType.Validation, "Note title cannot be empty"));
// 未找到路径return Result<Note>.Failure(Error.NotFound("Note", noteId));Error 类型使用 {模块}.{场景}.{原因} 编码并携带结构化详情。端点将 Result<T> 转换为适当的 HTTP 响应。
验证 — IRequestRule
BitzOrcas 使用 IRequestRule 进行验证(非 FluentValidation)。规则附加到命令上:
public sealed class CreateGreetingCommandRule : IRequestRule<CreateGreetingCommand>{ public ValueTask<Error?> ValidateAsync( CreateGreetingCommand request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(request.Message)) return ValueTask.FromResult<Error?>( new Error(ErrorType.Validation, "Message is required."));
if (request.Message.Length > 500) return ValueTask.FromResult<Error?>( new Error(ErrorType.Validation, "Message must not exceed 500 characters."));
return ValueTask.FromResult<Error?>((Error?)null); }}ValidationPipelineBehavior 在处理器之前运行所有已注册的 IRequestRule<T> 实现。规则与处理器一起在 DI 中注册。
Mediator 管道
每个请求在到达处理器之前经过管道:
LoggingPipelineBehavior → 计时 + 异常兜底 → AuthorizationPipelineBehavior → 默认拒绝授权 → ValidationPipelineBehavior → IRequestRule 验证 → IdempotencyPipelineBehavior → IIdempotentRequest 去重 → TransactionPipelineBehavior → UoW 提交/回滚(仅命令) → ActivityAuditPipelineBehavior → 活动审计 → DomainEventDispatchPipelineBehavior → 分发领域事件 → [处理器]查看 Mediator 管道图了解可视化流程。
约定
- 处理器使用
public sealed。 Mediator 的源代码生成器需要具体类型。 - 处理器返回
ValueTask<T>。 对同步快速路径分配友好。 - 每个
await使用.ConfigureAwait(false)。 这是库代码;永远不捕获同步上下文。 - 命令和查询是
record。 首选不可变性和相等性。 - 端点是
IEndpointRouteBuilder上的静态扩展方法。 无控制器。只有 Minimal APIs。 - 每个切片一个端点。 一个切片是一个特性;一个特性有一个 HTTP 入口。
- 规则与处理器放在一起。 在 DI 中自动注册。
- 权限定义在 Contracts 中。 端点通过
.RequirePermission(...)引用它们。
CQRS 无仪式感
VSA 与 CQRS 自然配合。区分在于两个标记接口:
public sealed record CreateNoteCommand(string Title, string Content) : ICommand<Result<Note>>;public sealed record GetNoteByIdQuery(Guid Id) : IQuery<Result<Note>>;TransactionPipelineBehavior 仅对 ICommand 消息运行(不对 IQuery),因此查询处理器跳过 UoW 提交。IdempotencyPipelineBehavior 仅处理实现 IIdempotentRequest 的消息。
VSA 不做的事情
- 它不替代你的领域模型。 聚合仍然执行不变量。VSA 是关于如何组织特性。
- 它不强制数据库隔离。 每个模块的适配器共享同一数据库,通过
TenantEntityBase实现租户隔离。 - 它不使架构测试变为可选。 ArchUnitNET 强制命名空间边界——切片分散逻辑,架构测试防止漂移。