Most of us have worked on a codebase where adding one small field to a form meant touching five different files spread across three layers. You update the entity, then the DTO, then the validator, then the controller, and finally the service method that ties everything together. This is the daily reality of a layered architecture once the codebase grows past a certain size, and it gets tiring fast.
Vertical Slice Architecture, usually shortened to VSA, takes a different view. Instead of organizing code by technical layer, you organize it by feature. Each feature, or slice, owns everything it needs: the request, the endpoint, the validation, and the data access. Jimmy Bogard, the creator of MediatR and AutoMapper, is generally credited with popularizing this approach, and it has become one of the more actively debated topics in .NET architecture circles over the last couple of years.
The problem with layered architectures
Layered architectures such as N-tier or Clean Architecture organize a system into strict tiers: Domain, Application, Infrastructure, Presentation. Dependencies flow in one direction, and each layer has a clearly defined job. This gives you structure and predictability, but it also means a single feature ends up scattered across every layer in the system.
The coupling here runs backwards from what you actually want on a day to day basis. Inside a layer, cohesion is high, all your validators live together and all your repositories live together, but across a feature, cohesion is low. To trace what happens when a customer places an order, you end up jumping across five or six files sitting in five or six different folders. More abstractions between layers also means more indirection, and more indirection means more time spent reading code instead of writing it.
What Vertical Slice Architecture actually changes
A vertical slice cuts straight through the stack for exactly one use case. Instead of a Presentation layer, an Application layer, a Domain layer, and an Infrastructure layer each containing a small piece of every feature, each feature owns its own endpoint, its own handler, and its own data access, grouped together in one place.

Add a new feature and you add a new slice, you are not modifying four existing files that happen to belong to unrelated features. This is the practical benefit that gets most teams to try VSA in the first place: new work is additive rather than surgical. It also means two developers working on two different features rarely touch the same file, which cuts down on merge conflicts in a way that layered codebases struggle with.
Structuring a single slice
A common way to organize an API around vertical slices is the REPR pattern, short for Request, Endpoint, Response. Each folder under a Features directory represents one use case, and everything that use case needs lives inside it. Here is what that looks like for a small order API, using minimal APIs instead of controllers.

Here is a single vertical slice built as a minimal API endpoint. This is the CreateProduct feature: the request record, the response record, and the handler that talks directly to the DbContext, all inside one static class.
public static class CreateProduct
{
public record Request(string Name, decimal Price);
public record Response(int Id, string Name, decimal Price);
public class Endpoint : IEndpoint
{
public void MapEndpoint(IEndpointRouteBuilder app)
{
app.MapPost("products", Handler).WithTags("Products");
}
public static IResult Handler(Request request, AppDbContext context)
{
var product = new Product
{
Name = request.Name,
Price = request.Price
};
context.Products.Add(product);
context.SaveChanges();
return Results.Ok(
new Response(product.Id, product.Name, product.Price));
}
}
}
Everything related to creating a product lives in this one file. There is no separate controller, no separate service class, and no repository interface to implement purely to satisfy a layer boundary. Notice that the handler calls context.SaveChanges() directly on AppDbContext. This is a deliberate trade-off: the slice is coupled to EF Core, and that is fine as long as your team is honest about accepting it. If a particular slice later needs a different persistence approach, say Dapper for a heavy reporting query, you rewrite that one file. Nothing else in the codebase needs to change.
One thing worth flagging for teams new to this pattern: each slice will naturally end up being either a command or a query, since that is how HTTP APIs are shaped anyway. This gets you most of the benefit of CQRS without introducing a separate library or a MediatR pipeline, though nothing stops you from layering MediatR on top if your team already has that convention.
Validation without a shared validation layer
Cross-cutting concerns do not disappear in VSA, they just move location. Validation is the most common one, and FluentValidation fits naturally because each slice can define its own Validator class scoped to its own Request type.
public class Validator : AbstractValidator<Request>
{
public Validator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
RuleFor(x => x.Price).GreaterThanOrEqualTo(0);
}
}
This validator is registered through dependency injection like any other service, so it can be injected straight into the endpoint handler alongside the DbContext.
public static async Task<IResult> Handler(
Request request,
IValidator<Request> validator,
AppDbContext context)
{
var validationResult = await validator.ValidateAsync(request);
if (!validationResult.IsValid)
{
return Results.BadRequest(validationResult.Errors);
}
// create product and return response
}
This runs the validation rules before anything touches the database, and returns a 400 with the specific field errors when it fails. A mistake I see fairly often here is forgetting to actually call ValidateAsync before proceeding, since nothing forces you to wire it in the way a MediatR pipeline behavior would. If your team is on MediatR already, moving this validation into a pipeline behavior is worth it, it applies uniformly across every command and query without each handler having to remember to call it.
The real question: where does shared logic go
This is where most teams run into trouble with VSA, and it usually shows up a few months in. You build CreateOrder, then UpdateOrder, then GetOrder. The address validation logic is now duplicated in three places, and the pricing calculation is needed by both the Cart feature and the Checkout feature.
The instinctive fix is to create a Common project or a SharedServices folder. Resist this. A Common project quickly becomes a junk drawer holding three unrelated concerns with three different rates of change, and you end up recreating the exact coupling that VSA was supposed to remove. A cleaner way to think about it is in three tiers.
The first tier is technical infrastructure, and this can be shared freely. Logging, database connection factories, auth middleware, and a Result type for representing success or failure all belong here because they rarely change due to business requirements.
public readonly record struct Result
{
public bool IsSuccess { get; }
public string Error { get; }
private Result(bool isSuccess, string error)
{
IsSuccess = isSuccess;
Error = error;
}
public static Result Success() => new(true, string.Empty);
public static Result Failure(string error) => new(false, error);
}
This Result struct is used across every slice in the system, and that is fine, because it is plumbing, not a business rule. It does not couple any two features to each other in a way that would make one feature break when you change another.
The second tier is domain concepts, and the right move here is usually to push shared business logic down into the entity itself rather than duplicating it across handlers.
public class Order
{
public Guid Id { get; private set; }
public OrderStatus Status { get; private set; }
public List<OrderLine> Lines { get; private set; }
public bool CanBeCancelled() => Status == OrderStatus.Pending;
public Result Cancel()
{
if (!CanBeCancelled())
{
return Result.Failure("Only pending orders can be cancelled.");
}
Status = OrderStatus.Cancelled;
return Result.Success();
}
}
Now CancelOrder, GetOrder, and UpdateOrder all rely on the same CanBeCancelled rule, defined exactly once on the entity. Different vertical slices sharing the same domain model is completely normal and is not a violation of the pattern, only shared service classes that bundle unrelated behaviors are the actual problem.
The third tier is feature-specific logic that only a small family of related slices needs. For this, a Shared folder scoped inside the feature grouping, for example Features/Orders/Shared, works better than a global one. If the entire Orders feature is ever removed, its local shared code goes with it, and there is no orphaned utility class left behind for someone to find two years later and wonder if it is still used.
Sharing logic across unrelated features
Sharing gets harder when two unrelated features both need the same thing. CreateOrder needs to check if a customer exists, and GenerateInvoice needs to calculate tax. The instinct is to look for somewhere neutral to put a shared service, but the first question worth asking is whether you actually need to share anything at all.
Most cross-feature sharing turns out to be data access wearing a disguise. If CreateOrder needs customer data, it can query the database directly rather than calling into a CustomersService. The Customer entity itself is shared because it lives in the domain layer, but there does not need to be a shared service sitting between the two features.
When there genuinely is shared logic, for example a tax calculation used by both Orders and Invoices, it belongs in a domain service rather than a feature-specific class.
public class TaxCalculator
{
public decimal CalculateTax(Address address, decimal subtotal)
{
var rate = GetTaxRate(address.State, address.Country);
return subtotal * rate;
}
}
Both CreateOrder and GenerateInvoice can call TaxCalculator directly without depending on each other’s code at all. If you find yourself needing to trigger a side effect in a different feature, for example sending a notification when an order ships, reaching for domain events or a message queue is usually a better fit than a direct method call across feature boundaries.
There is also a case where two slices look identical but are not actually the same thing. GetOrderResponse and CreateOrderResponse might have the exact same three properties today, which makes merging them into one shared DTO tempting. The problem shows up later, once GetOrder needs a tracking URL that CreateOrder cannot have yet because the order has not shipped. A shared DTO forces a nullable property that is confusing half the time. Duplication here is genuinely cheaper than the wrong abstraction, and it is worth resisting the urge to deduplicate purely because two things happen to look the same this week.
Combining VSA with Clean Architecture and CQRS
Vertical Slice Architecture and Clean Architecture answer two different questions, and treating them as mutually exclusive is a common misunderstanding. VSA organizes behavior around features. Clean Architecture controls the direction dependencies are allowed to flow in. You can combine both: keep your domain rules independent of infrastructure where that separation genuinely matters, without forcing every simple CRUD slice through the same five abstractions a complex slice needs.
CQRS falls out of VSA almost automatically, since a slice is naturally either a command that changes state or a query that reads it. Whether you implement that with plain minimal API handlers, as shown above, or with MediatR commands and queries is largely a team preference. MediatR adds a pipeline you can hook validation and logging into, at the cost of an extra layer of indirection for every request. Plain handlers are simpler to step through in a debugger but push you to be more disciplined about where cross-cutting logic lives.
Testing vertical slices
Vertical slices test well because each one is a self-contained unit with a clear input and a clear output. The recommended approach is to test through the public feature boundary, meaning an actual HTTP call using WebApplicationFactory, running against a real database rather than mocks, so routing, model binding, validation, and persistence are all exercised together in one test.
Testcontainers works well here for spinning up a throwaway Postgres or SQL Server instance per test run, which keeps the tests honest about how the slice behaves against a real database engine instead of an in-memory provider that quietly hides bugs around transactions or case sensitivity. Reserve pure unit tests for logic you have deliberately extracted onto a domain entity or a domain service, once that logic has enough branching to justify testing it in isolation from HTTP and the database.
When this pattern is worth adopting
VSA tends to work best for APIs with a large number of fairly independent features, modular monoliths, and teams where multiple people ship features in parallel without wanting to fight over the same service class. It removes a lot of the ceremony that layered architectures impose for the sake of consistency.
It is a weaker fit if your domain has a genuinely large amount of shared business logic upfront, where features are naturally intertwined rather than independent. It also shifts real responsibility onto the team: Clean Architecture’s guardrails prevent certain mistakes by construction, while VSA trusts you to apply the Rule of Three and to recognize when something has quietly become a Common folder in disguise. Code review needs to actively watch for that drift, since nothing in the compiler will catch it for you.
Migrating an existing layered codebase to VSA does not have to be an all-or-nothing rewrite. You can retrofit it feature by feature, starting with the next new feature you build rather than the oldest existing one, and let older code migrate opportunistically whenever it needs touching anyway.
Leave a Reply