Skip to content
fullstackhero

Concept

Vertical Slice Architecture

How each module is structured inside — one feature, one folder, one PR; endpoint + command + handler + validator + tests living together.

views 0 Last updated

Inside every fullstackhero module, Vertical Slice Architecture (VSA) determines the shape. A feature — registering a user, changing a product price, sending a chat message — lives in one folder with the endpoint, the command, the handler, the validator, and the tests. No layered round-trip. No Domain → Application → Infrastructure → API hops. One feature, one folder, one PR.

The slice shape

A typical feature folder under Modules.Identity/Features/v1/Users/RegisterUser/ looks like this:

RegisterUser/
├── RegisterUserCommand.cs ← Contracts (record + ICommand<TResponse>)
├── RegisterUserResponse.cs ← Contracts (record returned by the handler)
├── RegisterUserEndpoint.cs ← Runtime (minimal API mapping)
├── RegisterUserCommandHandler.cs ← Runtime (Mediator handler)
└── RegisterUserCommandValidator.cs ← Runtime (FluentValidation)

The command + response are in Modules.Identity.Contracts/v1/Users/RegisterUser/ because they’re the public surface anyone (including other modules) reads. The endpoint, handler, and validator are in the runtime project because they’re private to the module.

Tests for the slice live in Tests/Identity.Tests/Features/v1/Users/RegisterUser/ mirroring the source path. Architecture tests enforce the mirroring so the folder shape stays consistent.

A complete slice

The cleanest way to learn VSA is to read one slice end-to-end. Here’s RegisterUser, condensed:

Modules.Identity.Contracts/v1/Users/RegisterUser/RegisterUserCommand.cs
public sealed record RegisterUserCommand(
string FirstName,
string LastName,
string Email,
string UserName,
string Password,
string ConfirmPassword,
string? PhoneNumber = null,
string? Origin = null)
: ICommand<RegisterUserResponse>;
public sealed record RegisterUserResponse(Guid UserId, string Email);
Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandValidator.cs
public sealed class RegisterUserCommandValidator : AbstractValidator<RegisterUserCommand>
{
public RegisterUserCommandValidator(IUserService users)
{
RuleFor(x => x.FirstName).NotEmpty().MaximumLength(75);
RuleFor(x => x.LastName ).NotEmpty().MaximumLength(75);
RuleFor(x => x.Email).NotEmpty().EmailAddress()
.MustAsync(async (email, ct) => !await users.ExistsAsync(email, ct).ConfigureAwait(false))
.WithMessage("Email is already in use.");
RuleFor(x => x.Password).NotEmpty().MinimumLength(12);
RuleFor(x => x.ConfirmPassword).NotEmpty()
.Equal(x => x.Password).WithMessage("Passwords don't match.");
}
}
Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandHandler.cs
public sealed class RegisterUserCommandHandler(IUserRegistrationService registration)
: ICommandHandler<RegisterUserCommand, RegisterUserResponse>
{
public ValueTask<RegisterUserResponse> Handle(RegisterUserCommand cmd, CancellationToken ct)
=> registration.RegisterAsync(cmd, ct).ConfigureAwait(false);
}
Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserEndpoint.cs
public static class RegisterUserEndpoint
{
internal static RouteHandlerBuilder MapRegisterUserEndpoint(this IEndpointRouteBuilder endpoints)
=> endpoints.MapPost("/users/register",
(RegisterUserCommand command, IMediator mediator, CancellationToken ct)
=> mediator.Send(command, ct))
.WithName("RegisterUser")
.WithSummary("Register a new user")
.RequirePermission(IdentityPermissions.Users.Create)
.WithIdempotency(); // Idempotency-Key header support
}

That’s the entire feature. Five small files. One folder. One PR.

Conventions

The kit enforces a small set of conventions through code review and architecture tests:

  • Handlers are public sealed. Mediator’s source generator requires concrete types; sealed is the default modifier so nothing inherits handlers.
  • 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. Immutable, value-equal, source-gen-friendly.
  • Endpoints are static extension methods on IEndpointRouteBuilder. No controllers. No [ApiController]. Just minimal APIs.
  • One endpoint per slice. A slice represents one feature; one feature has one HTTP entrypoint. The “Update Product Price” feature has PATCH /products/{id}/price; “Update Product” has PUT /products/{id} — separate slices because they raise different domain events.
  • Validators live next to handlers. The module loader auto-registers FluentValidation validators from each module’s assembly.
  • Permissions live in Contracts/Authorization/. Endpoints reference them via .RequirePermission(...). Permission constants are registered once during module startup.

Why not layered Clean Architecture?

Clean Architecture (Jason Taylor, Ardalis) is a valid alternative — many teams use it well. The kit picks VSA because most production changes are cohesive feature changes, not cross-cutting refactors. When a stakeholder asks “can we add a discount code to product pricing?”, you want one folder to open, not five.

The trade-off: cross-cutting refactors (rename Product.Sku to Product.SkuCode everywhere) are slightly more tedious in VSA because the affected slices are scattered. Modern IDEs handle this fine; if your team makes a lot of cross-cutting changes, layered architecture is a better fit. If your team ships features more than it refactors abstractions, VSA wins.

The kit’s three architectural pillars — modular monolith, vertical slice inside each module, integration events across modules — combine well. Each module is a clean unit of ownership; each slice is a clean unit of change.

CQRS without ceremony

VSA goes well with CQRS — separating commands (writes that emit events) from queries (reads that project data). The kit uses Mediator 3 (source-generated, no runtime reflection) so the distinction is just two marker interfaces:

public sealed record CreateProductCommand(...) : ICommand<ProductResponse>;
public sealed record GetProductByIdQuery(Guid Id) : IQuery<ProductResponse>;

Handlers implement ICommandHandler<TCommand, TResponse> or IQueryHandler<TQuery, TResponse>. Mediator’s source generator wires the dispatch table at compile time. There’s no IRequestHandler<,> magic, no runtime reflection, no startup scan.

For most slices, command and query handlers look almost the same — they take a DTO, do the work, return a DTO. The point isn’t ceremony, it’s intent: a Query handler is allowed to skip SaveChanges; a Command handler is allowed to RaiseDomainEvent. Keeping the convention makes the read / write distinction obvious to future readers.

What VSA doesn’t do

  • It doesn’t replace your domain model. Aggregates still live in the Domain/ folder; the slice’s handler invokes domain methods. VSA is about how features are organized; it has no opinion on how aggregates enforce invariants.
  • It doesn’t enforce database isolation. Each module’s DbContext is its own slice of the DB, but inside a module multiple slices share the same DbContext. Use the Specification pattern (from the Persistence block) to keep queries reusable.
  • It doesn’t make architecture tests optional. Slices spread the logic; arch tests make sure the spread doesn’t drift into chaos. Read src/Tests/Architecture.Tests/ to see what’s enforced.