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 feature folder: the endpoint, the handler, and the validator together, with the command and response mirrored in the module’s .Contracts project. 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 two projects. RegisterUser looks like this:
Modules.Identity.Contracts/v1/Users/RegisterUser/├── RegisterUserCommand.cs ← public surface (ICommand<TResponse>)└── RegisterUserResponse.cs ← public surface (returned by the handler)
Modules.Identity/Features/v1/Users/RegisterUser/├── RegisterUserEndpoint.cs ← minimal API mapping├── RegisterUserCommandHandler.cs ← Mediator handler└── RegisterUserCommandValidator.cs ← FluentValidationThe command + response live in Contracts because they’re the public surface anyone (including other modules) reads. The endpoint, handler, and validator live in the runtime project because they’re private to the module.
Tests for the slice live in the module’s test project (e.g. src/Tests/Identity.Tests/Handlers/RegisterUserCommandHandlerTests.cs), and src/Tests/Architecture.Tests/ enforces the structural rules — handlers paired with validators, Contracts free of EF/FluentValidation, versioned features not reaching into newer versions.
A complete slice
The cleanest way to learn VSA is to read one slice end-to-end. Here’s RegisterUser, condensed:
public class RegisterUserCommand : ICommand<RegisterUserResponse>{ public string FirstName { get; set; } = default!; public string LastName { get; set; } = default!; public string Email { get; set; } = default!; public string UserName { get; set; } = default!; public string Password { get; set; } = default!; public string ConfirmPassword { get; set; } = default!; public string? PhoneNumber { get; set; }
[JsonIgnore] public string? Origin { get; set; } // set by the endpoint, not the client}
// Modules.Identity.Contracts/v1/Users/RegisterUser/RegisterUserResponse.cspublic record RegisterUserResponse(string UserId);public sealed class RegisterUserCommandValidator : AbstractValidator<RegisterUserCommand>{ public RegisterUserCommandValidator() { RuleFor(x => x.FirstName).NotEmpty().MaximumLength(100); RuleFor(x => x.LastName).NotEmpty().MaximumLength(100); RuleFor(x => x.Email).NotEmpty().EmailAddress(); RuleFor(x => x.UserName).NotEmpty().MinimumLength(3).MaximumLength(50); RuleFor(x => x.Password).NotEmpty().MinimumLength(6); RuleFor(x => x.ConfirmPassword).NotEmpty() .Equal(x => x.Password).WithMessage("Passwords do not match."); }}public sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, RegisterUserResponse>{ private readonly IUserService _userService;
public RegisterUserCommandHandler(IUserService userService) => _userService = userService;
public async ValueTask<RegisterUserResponse> Handle(RegisterUserCommand command, CancellationToken cancellationToken) { string userId = await _userService.RegisterAsync( command.FirstName, command.LastName, command.Email, command.UserName, command.Password, command.ConfirmPassword, command.PhoneNumber ?? string.Empty, command.Origin ?? string.Empty, cancellationToken).ConfigureAwait(false);
return new RegisterUserResponse(userId); }}public static class RegisterUserEndpoint{ internal static RouteHandlerBuilder MapRegisterUserEndpoint(this IEndpointRouteBuilder endpoints) { return endpoints.MapPost("/register", async (RegisterUserCommand command, HttpContext context, IMediator mediator, CancellationToken cancellationToken) => { command.Origin = $"{context.Request.Scheme}://{context.Request.Host.Value}{context.Request.PathBase.Value}"; var result = await mediator.Send(command, cancellationToken); return TypedResults.Created($"/api/v1/identity/users/{result.UserId}", result); }) .WithName("RegisterUser") .WithSummary("Register user") .RequirePermission(IdentityPermissions.Users.Create) .WithIdempotency(); // Idempotency-Key header support }}That’s the entire feature. Five small files. One slice. 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;sealedis the default modifier so nothing inherits handlers. - Handlers return
ValueTask<T>. Allocation-friendly for the synchronous fast path. - Every
awaituses.ConfigureAwait(false). This is library code; never capture the synchronization context. - Commands and queries are
records orsealedclasses. Enforced by an architecture test (Commands_And_Queries_Should_Be_Records_Or_Sealed). Prefer records; reach for a class when the endpoint needs to enrich the command (likeRegisterUserCommand.Originabove). - 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.
ChangeProductPricehasPATCH /products/{productId:guid}/price;UpdateProducthasPUT /products/{productId:guid}— separate slices because they’re separate operations with separate 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
DbContextis its own slice of the DB, but inside a module multiple slices share the sameDbContext. 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.
Related
- Modular monolith — the outer structure that contains the slices.
- Modules overview — every module is structured this way.
- Catalog module — the most slice-dense module; read its
Features/v1/for examples. - Dependency injection — how the module loader wires slice handlers.