
Learn vertical slice architecture in .NET with C# examples. Organize code by feature, not layer — with MediatR, minimal APIs, and best practices. Start now!
What Is Vertical Slice Architecture in .NET?
Vertical slice architecture is a way of organizing your .NET codebase by feature instead of by technical layer. Rather than spreading a single feature across a Controllers folder, a Services folder, a Repositories folder, and a DTOs folder, you put everything that feature needs — the endpoint, the request, the handler, the validation, and the data access — into one folder, one "slice." When you need to change how "Create Order" works, you open one folder, not five projects.
The term was popularized by Jimmy Bogard (creator of MediatR and AutoMapper), and it has become one of the most talked-about approaches to ASP.NET Core project structure. In this guide, you'll learn why vertical slice architecture is winning over teams that used to swear by n-tier and Clean Architecture, how to implement it with minimal APIs and MediatR, and the pitfalls to avoid before you restructure your solution.
Why Layered Architecture Falls Short
The classic layered (or "onion") solution looks tidy on day one:
MyApp.Api— controllersMyApp.Application— services, interfaces, DTOsMyApp.Domain— entitiesMyApp.Infrastructure— repositories, EF Core
The problem is that almost no change you ever make is confined to one layer. Adding a field to "Update Customer" means touching the controller, the DTO, the mapping profile, the service interface, the service implementation, the repository interface, the repository, and the entity. Eight files across four projects for one business change. That's high coupling across layers disguised as low coupling within them.
Worse, layered architectures push you toward shared abstractions — the ICustomerService with 27 methods, the generic repository that every feature must squeeze through. Over time, changing anything risks breaking everything, because everything flows through the same choke points.
Vertical slice architecture flips the axis. Coupling is high inside a slice (which is fine — that code changes together) and low between slices (which is what actually protects you). This is the classic principle: maximize cohesion, minimize coupling — applied to how requests flow through your system.
Vertical Slice Architecture vs Clean Architecture
This is the comparison most developers search for, so let's be direct. Clean Architecture and vertical slice architecture are not enemies — they answer different questions.
- Clean Architecture answers: "In which direction should dependencies point?" (Inward, toward the domain.)
- Vertical slice architecture answers: "How should I group code that changes together?" (By feature.)
Many successful teams combine them: slices for organization, with a shared domain model at the core for genuinely shared business rules. Where they conflict is ceremony. Clean Architecture, applied dogmatically, demands interfaces and mapping at every boundary even when a feature is a simple read. Vertical slices let each feature choose its own weight: a dashboard query can hit the database directly with Dapper, while a complex "Place Order" command can use a rich domain model — in the same application, without hypocrisy.
Project Structure: Organizing by Feature
Here's a typical feature-folder layout for an ASP.NET Core application using vertical slices:
src/
MyShop.Api/
Features/
Orders/
CreateOrder.cs // request, validator, handler, endpoint
GetOrder.cs
CancelOrder.cs
OrdersModule.cs // endpoint registration
Products/
SearchProducts.cs
CreateProduct.cs
Common/
Behaviors/ // cross-cutting: validation, logging
Persistence/ // DbContext
Program.cs
Notice what's missing: no Services folder, no Repositories folder, no DTOs folder. Each file is a complete, self-contained use case. New developers can find "the code that cancels an order" in seconds — it's in Features/Orders/CancelOrder.cs.
Building a Vertical Slice with MediatR and Minimal APIs
Let's build a complete, runnable slice. First, the packages:
// dotnet add package MediatR
// dotnet add package FluentValidation.DependencyInjectionExtensions
// dotnet add package Microsoft.EntityFrameworkCore.Sqlite
Here is the entire "Create Order" feature in one file. The request, validator, handler, and response live together as nested types inside a static class named after the feature:
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace MyShop.Api.Features.Orders;
public static class CreateOrder
{
// The request — what the client sends
public record Command(string CustomerEmail, List<LineItem> Items)
: IRequest<Response>;
public record LineItem(int ProductId, int Quantity);
// The response — what the client gets back
public record Response(int OrderId, decimal Total);
// Validation lives with the feature, not in a global folder
public class Validator : AbstractValidator<Command>
{
public Validator()
{
RuleFor(x => x.CustomerEmail).NotEmpty().EmailAddress();
RuleFor(x => x.Items).NotEmpty();
RuleForEach(x => x.Items)
.Must(i => i.Quantity > 0)
.WithMessage("Quantity must be positive.");
}
}
// The handler — the actual business logic
public class Handler(ShopDbContext db) : IRequestHandler<Command, Response>
{
public async Task<Response> Handle(
Command request, CancellationToken ct)
{
var productIds = request.Items.Select(i => i.ProductId).ToList();
var products = await db.Products
.Where(p => productIds.Contains(p.Id))
.ToDictionaryAsync(p => p.Id, ct);
var order = new Order { CustomerEmail = request.CustomerEmail };
foreach (var item in request.Items)
{
if (!products.TryGetValue(item.ProductId, out var product))
throw new ProductNotFoundException(item.ProductId);
order.AddLine(product, item.Quantity);
}
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return new Response(order.Id, order.Total);
}
}
}
The minimal API endpoint maps straight to the slice:
public static class OrdersModule
{
public static void MapOrderEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/orders").WithTags("Orders");
group.MapPost("/", async (
CreateOrder.Command command, ISender sender, CancellationToken ct) =>
{
var response = await sender.Send(command, ct);
return Results.Created($"/api/orders/{response.OrderId}", response);
});
group.MapGet("/{id:int}", async (
int id, ISender sender, CancellationToken ct) =>
{
var response = await sender.Send(new GetOrder.Query(id), ct);
return response is null ? Results.NotFound() : Results.Ok(response);
});
}
}
And Program.cs wires it all together:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<ShopDbContext>(o =>
o.UseSqlite("Data Source=shop.db"));
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
});
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
var app = builder.Build();
app.MapOrderEndpoints();
app.Run();
Cross-Cutting Concerns with Pipeline Behaviors
The question skeptics always ask: "Without layers, where does validation, logging, and transaction handling go?" The answer is MediatR pipeline behaviors — middleware for your handlers. Write it once, and every slice gets it automatically:
public class ValidationBehavior<TRequest, TResponse>(
IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
var failures = validators
.Select(v => v.Validate(request))
.SelectMany(r => r.Errors)
.Where(f => f is not null)
.ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
return await next();
}
}
This is the key insight: vertical slice architecture doesn't abandon separation of concerns — it separates cross-cutting concerns (validation, logging, auth) into behaviors while keeping feature concerns together.
Queries Can Be Simple — And That's the Point
Reads rarely need a domain model. In a slice, a query is free to be as thin as it wants:
public static class GetOrder
{
public record Query(int Id) : IRequest<Response?>;
public record Response(int Id, string CustomerEmail, decimal Total);
public class Handler(ShopDbContext db) : IRequestHandler<Query, Response?>
{
public async Task<Response?> Handle(Query request, CancellationToken ct)
=> await db.Orders
.Where(o => o.Id == request.Id)
.Select(o => new Response(o.Id, o.CustomerEmail, o.Total))
.FirstOrDefaultAsync(ct);
}
}
No repository, no service, no AutoMapper — a projection straight to the response. This natural split between commands and queries means vertical slices give you lightweight CQRS for free, without event sourcing or separate databases.
Best Practices for Vertical Slice Architecture
- One file per slice until it hurts. Nested types inside a static class keep everything discoverable. Split into a subfolder only when a slice genuinely outgrows a file.
- Push shared logic down, not sideways. When two slices need the same rule, move it into a domain entity method (like
order.AddLine()above) — not into a shared "service" that recouples your slices. - Test the whole slice. The best test for a slice is an integration test that sends the command through MediatR against a real (test-container or SQLite) database. You test behavior, not mocks.
- Use behaviors for anything you'd repeat in three or more handlers — validation, logging, idempotency, transactions.
- You don't need MediatR. Since MediatR moved to a commercial license, many teams use plain handler classes registered in DI, or libraries like Wolverine or FastEndpoints. The architecture is about folder-by-feature and self-contained use cases, not any specific package.
Common Pitfalls to Avoid
- Copy-paste drift. Slices tolerate some duplication — but business rules must not be duplicated. If a rounding rule exists in two handlers, it will eventually diverge. Duplicate structure, never rules.
- Slices calling slices. Sending a command from inside another handler rebuilds the spaghetti you left layers to escape. Extract shared domain logic instead, or raise a domain event.
- Fat "Common" folders. If your Common folder grows faster than your Features folder, you've rebuilt a layered architecture with extra steps.
- Restructuring a working app for fashion. If your layered codebase is healthy and the team ships fast, don't migrate. Adopt slices for new features first and let the styles coexist.
Conclusion: Should You Use Vertical Slice Architecture?
Vertical slice architecture aligns your codebase with how software actually changes: feature by feature, not layer by layer. By organizing code by feature, each use case becomes a self-contained unit you can read, test, modify, and delete without archaeology across five projects. Combined with minimal APIs, a mediator pipeline for cross-cutting concerns, and a shared domain model for genuinely shared rules, it scales from small APIs to large modular monoliths — and it's a natural stepping stone to microservices if you ever need them.
Key takeaways:
- Organize by feature, not layer — code that changes together should live together.
- Each slice chooses its own complexity: thin Dapper/EF projections for reads, rich domain logic for writes.
- Handle cross-cutting concerns once with pipeline behaviors, not per-layer boilerplate.
- Share business rules through the domain model; never let slices call each other.
- Vertical slice architecture and Clean Architecture answer different questions — you can combine them.
Start small: build your next feature as a single slice in a Features folder. Once you've changed a requirement by editing one file instead of eight, you'll understand why so many .NET teams aren't going back.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment