Vertical slice architecture inverts the traditional layered approach. Instead of organizing code by technical concern (controllers, services, repositories), you organize by feature. Each feature is a self-contained vertical slice through the application stack. After using this approach on two large projects, I am convinced it is a better default than layered architecture for most applications.
The Problem with Layers
In a layered architecture, adding a single feature requires touching files across multiple directories: a controller, a service, a repository, a DTO, a mapper, a validator. These files are scattered across the project, connected only by naming conventions. When you need to understand a feature, you are chasing references across the codebase.
// Layered architecture — one feature touches many folders
/Controllers/OrdersController.cs
/Services/IOrderService.cs
/Services/OrderService.cs
/Repositories/IOrderRepository.cs
/Repositories/OrderRepository.cs
/DTOs/CreateOrderDto.cs
/Validators/CreateOrderValidator.cs
/Mappers/OrderProfile.cs
Vertical Slices Keep Related Code Together
A vertical slice groups everything related to a single use case in one place. When you open the folder, you see everything that feature does.
// Vertical slice architecture — one folder per feature
/Features/Orders/CreateOrder/
CreateOrder.cs // Request + Response types
CreateOrderHandler.cs // Business logic
CreateOrderValidator.cs // Validation rules
CreateOrderEndpoint.cs // HTTP endpoint mapping
The handler contains the business logic and data access for this specific use case. It does not go through a generic repository or service layer.
public record CreateOrderCommand(
Guid CustomerId,
List<OrderLineDto> Lines) : IRequest<CreateOrderResponse>;
public record CreateOrderResponse(Guid OrderId);
public class CreateOrderHandler(AppDbContext db) : IRequestHandler<CreateOrderCommand, CreateOrderResponse>
{
public async Task<CreateOrderResponse> Handle(
CreateOrderCommand request,
CancellationToken ct)
{
var customer = await db.Customers
.FirstOrDefaultAsync(c => c.PublicId == request.CustomerId, ct)
?? throw new NotFoundException("Customer not found");
var order = new Order(customer.Id);
foreach (var line in request.Lines)
{
var product = await db.Products
.FirstOrDefaultAsync(p => p.PublicId == line.ProductId, ct)
?? throw new NotFoundException($"Product {line.ProductId} not found");
order.AddLine(product, line.Quantity);
}
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return new CreateOrderResponse(order.PublicId);
}
}
You Do Not Need a Repository for Every Slice
This is the most controversial aspect: vertical slices often use the DbContext directly instead of going through a repository abstraction. The reasoning is pragmatic — a repository that wraps a single EF Core query adds a layer of indirection without adding value. The DbContext is already a unit of work with change tracking.
When the same data access pattern is genuinely reused across multiple slices, extract it into a shared query object or extension method. But do not pre-emptively abstract everything behind repositories.
Mixing Approaches Is Fine
Not every feature warrants a full vertical slice. Simple CRUD endpoints can stay in traditional controllers. Complex features with rich business logic benefit most from the vertical slice approach. The architecture should serve the code, not the other way around.
// Simple CRUD — traditional controller is fine
app.MapGet("/api/products", async (AppDbContext db) =>
await db.Products.AsNoTracking().Select(p => new ProductDto(p.PublicId, p.Name)).ToListAsync());
// Complex feature — vertical slice with handler, validator, etc.
app.MapPost("/api/orders", async (CreateOrderCommand command, ISender sender) =>
Results.Created($"/api/orders/{(await sender.Send(command)).OrderId}", null));
The key insight is that vertical slice architecture is not about eliminating abstraction — it is about placing abstraction where it provides value, not where convention dictates.
Comments (2)
Thanks for sharing — very helpful.
I have a question: does this also apply to older versions?
Thanks for your comment — glad it helped!
I had the same experience, can confirm.
Leave a Comment