Skip to content
bitzorcas
EN

Concept

Vertical Slice Architecture

How each module is structured inside — one feature, one folder, one PR; endpoint + command + handler + rule + tests living together, with Result\<T\> and Mediator pipelines.

Last updated

Inside every BitzOrcas.Modern module, Vertical Slice Architecture (VSA) determines the shape. A feature — creating a note, fetching a greeting, sending a chat message — lives in one feature folder: the endpoint, the handler, and the validation rule together. No layered round-trip. No Domain → Application → Infrastructure → API hops. One feature, one folder, one PR.

The slice shape

A slice is split across the module’s projects. The CreateNote sandbox feature looks like this:

BitzOrcas.Application/Sandbox/Notes/
├── CreateNoteCommand.cs ← ICommand<Note> (record)
├── CreateNoteCommandHandler.cs ← ICommandHandler<CreateNoteCommand, Note>
└── GetNoteByIdQuery.cs ← IQuery<Note?> (record)
BitzOrcas.Application/Sandbox/Notes/ (handlers inline or separate)
├── CreateNoteCommandHandler.cs ← handler implementation
└── GetNoteByIdQueryHandler.cs ← handler implementation
BitzOrcas.Api/Endpoints/Sandbox/
├── NoteEndpoints.cs ← Minimal API endpoint mapping

A complete slice — CreateNote

The cleanest way to learn VSA is to read one slice end-to-end. Here’s CreateNote from the sandbox module:

CreateNoteCommand.cs
public sealed record CreateNoteCommand(string Title, string Content) : ICommand<Result<Note>>;
CreateNoteCommandHandler.cs
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);
}
}
NoteEndpoints.cs
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;
}
}

That’s the entire feature. Small files. One slice. One PR.

Result<T> — never throw for business failures

BitzOrcas enforces the Result<T> pattern from BitzOrcas.Domain. Business failures return Result.Failure(Error), never throw exceptions:

// Success path
return Result<Note>.Success(note);
// Business failure path
return Result<Note>.Failure(new Error(
ErrorType.Validation,
"Note title cannot be empty"));
// Not-found path
return Result<Note>.Failure(Error.NotFound("Note", noteId));

The Error type uses {Module}.{Scenario}.{Reason} codes and carries structured details. The endpoint translates Result<T> into appropriate HTTP responses.

Validation — IRequestRule

BitzOrcas uses IRequestRule for validation (not FluentValidation). Rules are attached to commands:

CreateGreetingCommandRule.cs
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);
}
}

The ValidationPipelineBehavior runs all registered IRequestRule<T> implementations before the handler. Rules are registered in DI alongside the handler.

The Mediator pipeline

Every request passes through the pipeline before reaching the handler:

LoggingPipelineBehavior → timing + exception catch-all
→ AuthorizationPipelineBehavior → fail-closed authorization
→ ValidationPipelineBehavior → IRequestRule validation
→ IdempotencyPipelineBehavior → dedup for IIdempotentRequest
→ TransactionPipelineBehavior → UoW commit/rollback (Command only)
→ ActivityAuditPipelineBehavior → activity audit
→ DomainEventDispatchPipelineBehavior → dispatch domain events
→ [Handler]

See the Mediator Pipeline diagram for a visual flow.

Conventions

  • Handlers are public sealed. Mediator’s source generator requires concrete types.
  • Handlers return ValueTask<T>. Allocation-friendly for the synchronous fast path.
  • Every await uses .ConfigureAwait(false). This is library code; never capture the synchronization context.
  • Commands and queries are records. Preferred for immutability and equality.
  • Endpoints are static extension methods on IEndpointRouteBuilder. No controllers. Just minimal APIs.
  • One endpoint per slice. A slice is one feature; one feature has one HTTP entrypoint.
  • Rules live next to handlers. Auto-registered in DI.
  • Permissions live in Contracts. Endpoints reference them via .RequirePermission(...).

CQRS without ceremony

VSA pairs naturally with CQRS. The distinction is two marker interfaces:

public sealed record CreateNoteCommand(string Title, string Content) : ICommand<Result<Note>>;
public sealed record GetNoteByIdQuery(Guid Id) : IQuery<Result<Note>>;

The TransactionPipelineBehavior only runs for ICommand messages (not IQuery), so query handlers skip the UoW commit. The IdempotencyPipelineBehavior only processes messages implementing IIdempotentRequest.

What VSA doesn’t do

  • It doesn’t replace your domain model. Aggregates still enforce invariants. VSA is about how features are organized.
  • It doesn’t enforce database isolation. Each module’s adapters share the same database, with tenant isolation via TenantEntityBase.
  • It doesn’t make architecture tests optional. ArchUnitNET enforces namespace boundaries — slices spread the logic, arch tests prevent drift.