Vertical Slice Architecture (VSA)
Core Principles
- Organize by feature, not by layer — Each feature is a self-contained vertical slice containing its endpoint, handler, request/response types, and validation. No more jumping between Controllers/, Services/, Repositories/ folders.
- Minimize cross-feature coupling — Features should not reference each other directly. Shared concerns go in a
Common/orShared/directory. - One file per feature is fine — A simple CRUD endpoint doesn't need 5 files spread across layers. Start with everything in one file, extract only when complexity demands it.
- The handler is the unit of work — Each handler does one thing. No god-services with 20 methods.
Patterns
Feature Folder Structure
src/ MyApp.Api/ Features/ Orders/ CreateOrder.cs # Request, Handler, Response, Endpoint — all in one file GetOrder.cs ListOrders.cs CancelOrder.cs Shared/ OrderMapper.cs # Shared within the Orders feature only Products/ CreateProduct.cs GetProduct.cs Common/ Behaviors/ ValidationBehavior.cs # Cross-cutting Mediator pipeline behavior Persistence/ AppDbContext.cs Extensions/ ServiceCollectionExtensions.cs Program.cs
Pattern A: Mediator Handlers (Recommended Default)
Source-generated mediator — MIT licensed, no reflection, Native AOT compatible. Uses IRequest<T> / IRequestHandler<TRequest, TResponse> with pipeline behaviors. Near-identical API to MediatR but faster and free. Package: Mediator.Abstractions + Mediator.SourceGenerator.
csharp// Features/Orders/CreateOrder.cs public static class CreateOrder { public record Command(string CustomerId, List<OrderItemDto> Items) : IRequest<Result<OrderResponse>>; public record OrderItemDto(string ProductId, int Quantity); public record OrderResponse(Guid Id, decimal Total, DateTime CreatedAt); public class Validator : AbstractValidator<Command> { public Validator() { RuleFor(x => x.CustomerId).NotEmpty(); RuleFor(x => x.Items).NotEmpty(); RuleForEach(x => x.Items).ChildRules(item => { item.RuleFor(x => x.ProductId).NotEmpty(); item.RuleFor(x => x.Quantity).GreaterThan(0); }); } } internal sealed class Handler(AppDbContext db, TimeProvider clock) : IRequestHandler<Command, Result<OrderResponse>> { public async ValueTask<Result<OrderResponse>> Handle(Command request, CancellationToken ct) { var order = Order.Create(request.CustomerId, request.Items, clock.GetUtcNow()); db.Orders.Add(order); await db.SaveChangesAsync(ct); return Result.Success(new OrderResponse(order.Id, order.Total, order.CreatedAt)); } } } // Registration in Program.cs or module DI builder.Services.AddMediator(); // Features/Orders/OrderEndpoints.cs — auto-discovered via IEndpointGroup public sealed class OrderEndpoints : IEndpointGroup { public void Map(IEndpointRouteBuilder app) { var group = app.MapGroup("/api/orders").WithTags("Orders"); group.MapPost("/", async (CreateOrder.Command command, ISender sender, CancellationToken ct) => { var result = await sender.Send(command, ct); return result.IsSuccess ? TypedResults.Created($"/api/orders/{result.Value.Id}", result.Value) : result.ToProblemDetails(); }) .WithName("CreateOrder").Produces<CreateOrder.OrderResponse>(201) .ProducesValidationProblem() .AddEndpointFilter<ValidationFilter<CreateOrder.Command>>(); } }
Pattern B: Wolverine Handlers
Convention-based — no interfaces to implement. Wolverine discovers handlers by method signature.
csharp// Features/Orders/CreateOrder.cs public static class CreateOrder { public record Command(string CustomerId, List<OrderItemDto> Items); public record OrderItemDto(string ProductId, int Quantity); public record OrderResponse(Guid Id, decimal Total, DateTime CreatedAt); // Wolverine discovers this by convention (static Handle method) public static async Task<Result<OrderResponse>> Handle( Command command, AppDbContext db, TimeProvider clock, CancellationToken ct) { var order = Order.Create(command.CustomerId, command.Items, clock.GetUtcNow()); db.Orders.Add(order); await db.SaveChangesAsync(ct); return Result.Success(new OrderResponse(order.Id, order.Total, order.CreatedAt)); } }
Pattern C: Raw Handler Classes (No Library)
Direct handler classes with no external dependency. Good for small projects or teams that want full control.
csharp// Features/Orders/CreateOrder.cs public static class CreateOrder { public record Command(string CustomerId, List<OrderItemDto> Items); public record OrderItemDto(string ProductId, int Quantity); public record OrderResponse(Guid Id, decimal Total, DateTime CreatedAt); internal class Handler(AppDbContext db, TimeProvider clock) { public async Task<Result<OrderResponse>> ExecuteAsync(Command command, CancellationToken ct) { var order = Order.Create(command.CustomerId, command.Items, clock.GetUtcNow()); db.Orders.Add(order); await db.SaveChangesAsync(ct); return Result.Success(new OrderResponse(order.Id, order.Total, order.CreatedAt)); } } } // Endpoint wiring — Result maps to HTTP response group.MapPost("/", async (CreateOrder.Command command, CreateOrder.Handler handler, CancellationToken ct) => { var result = await handler.ExecuteAsync(command, ct); return result.IsSuccess ? TypedResults.Created($"/api/orders/{result.Value.Id}", result.Value) : result.ToProblemDetails(); });
Adding Module Boundaries (Optional)
For larger applications that grow beyond a single project, introduce module boundaries. Each module is a separate class library with its own features and DbContext.
src/ MyApp.Api/ # Host — wires modules together Program.cs Modules/ ModuleExtensions.cs # app.MapOrderModule(), app.MapCatalogModule() MyApp.Orders/ # Module — own features, own DbContext Features/ CreateOrder.cs Persistence/ OrdersDbContext.cs OrdersModule.cs # IServiceCollection + IEndpointRouteBuilder extensions MyApp.Catalog/ # Module Features/ CreateProduct.cs Persistence/ CatalogDbContext.cs CatalogModule.cs
Modules communicate via:
- Integration events (preferred) — async, decoupled via Wolverine or MassTransit
- Shared contracts — a
MyApp.Contractsproject with DTOs/interfaces (use sparingly)
Shared Concerns
Cross-cutting concerns live outside feature folders:
csharp// Common/Behaviors/ValidationBehavior.cs (Mediator pipeline) public sealed class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators) : IPipelineBehavior<TRequest, TResponse> where TRequest : IMessage { public async ValueTask<TResponse> Handle( TRequest request, MessageHandlerDelegate<TRequest, TResponse> next, CancellationToken ct) { var context = new ValidationContext<TRequest>(request); var failures = validators .Select(v => v.Validate(context)) .SelectMany(r => r.Errors) .Where(f => f is not null) .ToList(); if (failures.Count > 0) throw new ValidationException(failures); return await next(request, ct); } }
Anti-patterns
Don't Create Layered Abstractions Within a Slice
csharp// BAD — a feature folder with its own service layer and repository Features/ Orders/ CreateOrder.cs IOrderService.cs # unnecessary abstraction OrderService.cs # unnecessary abstraction IOrderRepository.cs # unnecessary abstraction OrderRepository.cs # unnecessary abstraction // GOOD — handler talks directly to DbContext Features/ Orders/ CreateOrder.cs # handler uses AppDbContext directly
Don't Cross-reference Features Directly
csharp// BAD — CreateOrder directly calls GetProduct handler var product = await _getProductHandler.Handle(new GetProduct.Query(productId)); // GOOD — query the database directly or use a shared read model var product = await db.Products.FindAsync(productId, ct);
Don't Put Everything in One God Feature File
csharp// BAD — 500-line file with CRUD + business logic + mapping public static class Orders { // Create, Read, Update, Delete, Cancel, Refund, Export... } // GOOD — one file per operation Features/Orders/CreateOrder.cs Features/Orders/GetOrder.cs Features/Orders/CancelOrder.cs
Decision Guide
| Scenario | Recommendation |
|---|---|
| New project (default) | Pattern A — Mediator (source-generated, MIT, fast) |
| Need mediator + messaging in one lib | Pattern B — Wolverine (also handles events/queues) |
| Want full control, no dependencies | Pattern C — Raw handler classes |
| Existing MediatR codebase with license | Keep MediatR if licensed; otherwise migrate to Mediator (near-identical API) |
| Monolith growing complex | Add module boundaries, keep VSA within each module |
| Simple CRUD feature | Single file: request + handler + endpoint |
| Complex feature (saga, events) | Multiple files in feature folder, still colocated |
| Sharing logic between features | Extract to Common/ — not to another feature |

