Vertical Slice Architecture: Organising by Feature, Not Layer
In traditional layered architecture, you organise code by technical concern: controllers in one folder, services in another, repositories in a third. Vertical slice architecture flips this on its head — you organise by feature. Each feature is a self-contained slice that cuts through all layers, from the API endpoint down to the database.
The Problem with Layers
Consider adding a new feature to a layered project. You touch:
- A controller
- A service interface and implementation
- A repository interface and implementation
- DTOs, validators, mappers
These files are scattered across different folders and namespaces. A single feature change ripples across the entire project structure. Worse, shared abstractions like IGenericRepository<T> create coupling between features that have nothing in common.
What a Vertical Slice Looks Like
With vertical slices, each feature lives in its own folder with everything it needs.
Features/
CreateOrder/
CreateOrderEndpoint.cs
CreateOrderCommand.cs
CreateOrderHandler.cs
CreateOrderValidator.cs
GetOrderById/
GetOrderByIdEndpoint.cs
GetOrderByIdQuery.cs
GetOrderByIdHandler.cs
Each slice is independent. Changing CreateOrder doesn't affect GetOrderById at all.
A Slice with MediatR
MediatR is a natural fit for vertical slices. Each slice defines a request and a handler.
namespace MyApp.Features.CreateOrder;
public record CreateOrderCommand(
string CustomerEmail,
List<OrderLineDto> Lines) : IRequest<Guid>;
public record OrderLineDto(string Product, int Quantity, decimal UnitPrice);
public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, Guid>
{
private readonly AppDbContext _db;
public CreateOrderHandler(AppDbContext db) => _db = db;
public async Task<Guid> Handle(CreateOrderCommand request, CancellationToken ct)
{
var order = new Order
{
Id = Guid.NewGuid(),
CustomerEmail = request.CustomerEmail,
CreatedAt = DateTime.UtcNow,
Lines = request.Lines.Select(l => new OrderLine
{
Product = l.Product,
Quantity = l.Quantity,
UnitPrice = l.UnitPrice
}).ToList()
};
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct);
return order.Id;
}
}
Notice something important: the handler uses AppDbContext directly. In vertical slice architecture, you don't abstract for the sake of abstraction. Each slice uses whatever it needs, as simply as possible.
The Endpoint
With minimal APIs, the endpoint stays in the same folder:
namespace MyApp.Features.CreateOrder;
public static class CreateOrderEndpoint
{
public static void Map(IEndpointRouteBuilder app)
{
app.MapPost("/api/orders", async (
CreateOrderCommand command,
IMediator mediator,
CancellationToken ct) =>
{
var orderId = await mediator.Send(command, ct);
return Results.Created($"/api/orders/{orderId}", new { id = orderId });
});
}
}
Validation in the Slice
FluentValidation pairs well here. The validator lives alongside the handler:
namespace MyApp.Features.CreateOrder;
public class CreateOrderValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderValidator()
{
RuleFor(x => x.CustomerEmail)
.NotEmpty()
.EmailAddress();
RuleFor(x => x.Lines)
.NotEmpty()
.WithMessage("Order must contain at least one line.");
RuleForEach(x => x.Lines).ChildRules(line =>
{
line.RuleFor(l => l.Quantity).GreaterThan(0);
line.RuleFor(l => l.UnitPrice).GreaterThan(0);
});
}
}
Registering Everything
You can auto-discover endpoints at startup:
var featureEndpoints = typeof(Program).Assembly
.GetTypes()
.Where(t => t.GetMethod("Map", [typeof(IEndpointRouteBuilder)]) is not null);
foreach (var endpoint in featureEndpoints)
{
endpoint.GetMethod("Map")!.Invoke(null, [app]);
}
When Slices Share Code
Not everything is unique per feature. You'll still have shared concerns:
- Entities and DbContext — shared across slices
- Cross-cutting behaviours — logging, validation pipelines via MediatR behaviours
- Common DTOs — pagination, error responses
Put these in a Common/ or Shared/ folder. The rule isn't "never share" — it's "don't force sharing through artificial layers."
Vertical Slices vs Clean Architecture
These aren't mutually exclusive. You can use vertical slices within a Clean Architecture solution. The difference is in how you organise code:
- Clean Architecture optimises for protecting the domain from infrastructure
- Vertical slices optimise for feature independence and developer velocity
For teams where multiple developers work on separate features simultaneously, vertical slices reduce merge conflicts and make code reviews simpler — the entire feature is in one place.
When to Use It
Vertical slices work best for CRUD-heavy applications with many independent features, API-first projects, and teams that want to minimise cross-feature coupling. If your domain has deeply interconnected business rules, you may still want a shared domain layer — but organise your application logic in slices around it.