
Vertical Slice Architecture in ASP.NET Core: Features as First-Class Citizens
Vertical Slice Architecture flips the organizational instinct of layered systems — instead of grouping code by technical concern, you group it by use case. Here's how to structure, share, test, and migrate to slices in a real ASP.NET Core codebase.
The Problem with Layers at Scale
Layered architecture makes sense on a whiteboard. Controllers call services, services call repositories, repositories talk to the database. The problem appears when you trace a single feature — say, creating a shipment — across six files in four folders spanning three projects. Every change becomes a archaeological dig. The architecture that was supposed to impose order becomes the thing slowing you down.
Vertical Slice Architecture (VSA) offers a different organizing principle: group code by use case, not by technical role. One feature, one folder (or even one file). The guiding maxim is deliberately inverted from layered thinking: minimize coupling between slices, maximize coupling within a slice.
This isn't a new idea, but it has matured significantly in the .NET ecosystem. MediatR 14.1.0 (released March 3, 2026) now aligns to .NET's major release cadence, the REPR pattern has become a recognized structural unit, and the community has settled on concrete patterns for shared concerns and testing. Let's go through it properly.
Canonical Folder Structure
The top-level organizing unit is a Features/ folder. Inside it, one subfolder per domain area, then one subfolder (or file) per use case:
Features/
Shipments/
CreateShipment/
CreateShipment.Endpoint.cs
CreateShipment.Handler.cs
CreateShipment.Mapping.cs
CreateShipment.Validators.cs
GetShipmentById/
GetShipmentById.Endpoint.cs
GetShipmentById.Handler.cs
Users/
RegisterUser/
...
Common/
Behaviors/
ValidationBehavior.cs
LoggingBehavior.cs
Persistence/
AppDbContext.csThe REPR pattern (Request–Endpoint–Response) maps directly to this: each slice implements exactly one API request. The endpoint accepts the request model, dispatches to the handler, and returns the response model. Nothing bleeds out.
An alternative championed by Derek Comartin collapses all four files into a single CreateShipment.cs using nested static classes. Both approaches are valid. The multi-file approach wins on discoverability in large teams; the single-file approach wins on cognitive locality when the slice is genuinely small.
A Concrete Slice
Here's what a complete create-shipment slice looks like with MediatR 14.1.0 and Minimal APIs:
// Features/Shipments/CreateShipment/CreateShipment.Handler.cs
public static class CreateShipment
{
public record Command(string Origin, string Destination, decimal WeightKg)
: IRequest<Result>;
public record Result(Guid ShipmentId, string TrackingCode);
public sealed class Handler : IRequestHandler<Command, Result>
{
private readonly AppDbContext _db;
private readonly ITrackingCodeService _tracking;
public Handler(AppDbContext db, ITrackingCodeService tracking)
{
_db = db;
_tracking = tracking;
}
public async Task<Result> Handle(Command request, CancellationToken ct)
{
var code = await _tracking.GenerateAsync(ct);
var shipment = new Shipment
{
Id = Guid.NewGuid(),
Origin = request.Origin,
Destination = request.Destination,
WeightKg = request.WeightKg,
TrackingCode = code,
CreatedAt = DateTime.UtcNow
};
_db.Shipments.Add(shipment);
await _db.SaveChangesAsync(ct);
return new Result(shipment.Id, code);
}
}
}The endpoint registers itself as a static extension method, keeping startup noise out of Program.cs:
// Features/Shipments/CreateShipment/CreateShipment.Endpoint.cs
public static class CreateShipmentEndpoint
{
public static IEndpointRouteBuilder MapCreateShipment(this IEndpointRouteBuilder app)
{
app.MapPost("/shipments", async (
CreateShipment.Command command,
IMediator mediator,
CancellationToken ct) =>
{
var result = await mediator.Send(command, ct);
return Results.Created($"/shipments/{result.ShipmentId}", result);
})
.WithName("CreateShipment")
.WithTags("Shipments");
return app;
}
}In Program.cs you call app.MapCreateShipment(), or you scan for all endpoint registrars using reflection — either works.
Handling Cross-Cutting Concerns
The most common objection to VSA is: "Where do logging, validation, and exception handling go if not in a service layer?"
With MediatR, pipeline behaviors answer this cleanly. They act as middleware around every handler. Register them explicitly in AddMediatR — auto-scanning was removed in MediatR 12.3 and that behavior has not returned. Registration order defines execution order, so be deliberate:
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
cfg.LicenseKey = builder.Configuration["MediatR:LicenseKey"];
// Order matters: logging wraps everything, validation runs before handler
cfg.AddOpenBehavior(typeof(LoggingBehavior<,>));
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
cfg.AddOpenBehavior(typeof(ExceptionHandlingBehavior<,>));
});Note the LicenseKey assignment. As of MediatR 14.x, a license key is required for server-side use — client-side apps like Blazor WASM are exempt. Keys are available from Jimmy Bogard's company (Lucky Penny Software). If the commercial license is a blocker, a lightweight custom dispatcher is a realistic alternative: a generic IHandler<TRequest, TResponse> interface with assembly scanning, a Dispatcher class that resolves handlers from the DI container, and no external dependencies. Several teams have gone this route since MediatR 12.0 introduced breaking changes; it's not as much work as it sounds.
For non-MediatR cross-cutting concerns — authorization, rate limiting, response caching — ASP.NET Core middleware and endpoint filters remain the right tool. VSA doesn't replace the middleware pipeline; it replaces the service layer.
Truly shared code (base domain entities, the DbContext, utility extensions) lives in Common/ or Shared/. The discipline here is strategic abstraction: don't move something to Common/ because it appears twice. Move it when its contract is stable and its reuse is proven. Duplicating a two-line persistence call across three slices is often the right call.
Testing Strategy
Self-contained slices make the test surface explicit. The natural test grouping mirrors the feature grouping:
- Unit tests:
CreateShipmentTestscovers the handler with mockedAppDbContextandITrackingCodeService. The validator gets its own focused tests. - Integration tests: Spin up the full slice from endpoint to database using
WebApplicationFactory<Program>and a test database. Because the slice owns its own data access code, you're not leaking through shared repositories — the integration test boundary is the HTTP request.
One practical advantage: when a slice breaks, the test that fails tells you exactly which use case is broken. There's no ambiguity about which layer introduced the regression. Test class names like CreateShipmentHandlerTests also search cleanly in any IDE.
Avoid the trap of writing unit tests that mock every internal detail of a slice. If the handler does three database operations and some mapping, an integration test with a real (test) database is more valuable than a heavily mocked unit test that just verifies call counts.
Migrating from Layered or Clean Architecture
The best candidates for migration are codebases already using CQRS and MediatR — each handler already maps to a natural slice. The migration is mechanical:
- Create
Features/<Domain>/<UseCase>/folders. - Move the MediatR command/query and its handler into the new folder. Keep the handler's dependencies injected — nothing changes in the DI registration.
- Move the endpoint action (or controller method) into a matching endpoint file in the same folder.
- Move any DTOs specific to that use case alongside the handler.
- Leave shared infrastructure (DbContext, domain entities, shared validators) in
Common/or a dedicated infrastructure project.
The layered structure and slice structure can coexist indefinitely. You don't need a big-bang migration. A team of four can move one bounded context per sprint without disrupting running features.
If you're starting from the dotnet new cleanarchitecture template, VSA slots in without fighting the template — the CQRS scaffolding is already there. Mostly you're reorganizing folder ownership, not rewriting logic.
What VSA Is Not
VSA is a code organization strategy, not a complete application architecture. It doesn't tell you how to model your domain, handle transactions across aggregates, or manage eventual consistency. Those concerns — Domain-Driven Design, event sourcing, outbox patterns — sit orthogonally to VSA and combine with it fine.
It also isn't the same as feature slicing. Feature slicing is purely a folder convention. VSA additionally implies use-case boundary discipline and often (though not mandatorily) a dispatching pattern. You can have feature-sliced folders that are still fully layered inside each folder.
Consistency Enforcement
Without a layered contract enforcing what each layer may and may not reference, slices can drift. One developer reaches directly from an endpoint into the DbContext; another invents a service class inside a slice and starts injecting it into other slices. Prevent this with:
- A documented slice template (even a
.txtfile or Roslyn analyzer) that defines the four files and their responsibilities. - Architecture tests using
NetArchTestorArchUnitNETto assert that code inFeatures/does not reference types from other feature folders directly. - Code review checklists that ask: "Is any type from this slice referenced outside this slice?"
The architecture is only as strong as the team's discipline to maintain its boundaries. The upside is that those boundaries are intuitive — they match the way product managers describe features, which makes the conversation between engineering and product considerably simpler.
Sources
- Develop Vertical Slice Feature Folder with CQRS and MediatR in .NET 8 Microservices | by Mehmet Ozkaya | Medium
- Vertical Slice Architecture in .NET 10: The Ultimate Guide (2026)
- Vertical Slice Architecture in ASP.NET Core: A Modern Approach to Building Maintainable Applications - Asma's Blog
- Feature Slicing in C#: Organizing Code by Feature
- GitHub - jeangatto/ASP.NET-Core-Vertical-Slice-Architecture: ASP.NET Core, C#, Vertical Slice Architecture, CQRS, REST API, DDD, SOLID Principles · GitHub
- Vertical Slice Architecture
- Vertical Slice Architecture in ASP.NET Core - Code Maze
- Vertical Slices: ASP.NET Core | Frederik Baun's Blog
Keep reading

February 6, 2026 · 9 min
From Graph Traversal to Semantic Discovery: Adding Vector Search and LLM Reasoning to Your ArangoDB .NET 10 API
In the previous article, I built a .NET 10 API […]
Read
January 30, 2026 · 4 min
Mastering Graph Traversal in ArangoDB: Building High-Performance Path Finding APIs with .NET 10
Learn how to build high-performance graph traversal APIs using ArangoDB and .NET 10. This guide covers BFS path finding with PRUNE optimization, network expansion patterns, and AQL query techniques for knowledge graphs.
ReadOctober 20, 2025 · 16 min
Building a Retrieval-Augmented Generation (RAG) System in .NET
Introduction Retrieval-Augmented Generation (RAG) has become one of the most […]
Read