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:
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);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."); }}public sealed class RegisterUserCommandHandler(IUserRegistrationService registration) : ICommandHandler<RegisterUserCommand, RegisterUserResponse>{ public ValueTask<RegisterUserResponse> Handle(RegisterUserCommand cmd, CancellationToken ct) => registration.RegisterAsync(cmd, ct).ConfigureAwait(false);}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;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. 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” hasPUT /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
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.