Organize code by feature, not by layer
Controllers, Services, Repositories: a layered folder structure spreads one feature across five places. Vertical slices keep everything for a feature together.
Here's a familiar project layout:
Controllers/OrdersController.cs
Services/OrderService.cs
Services/IOrderService.cs
Repositories/OrderRepository.cs
Repositories/IOrderRepository.cs
Models/PlaceOrderRequest.cs
Validators/PlaceOrderValidator.cs
To change how an order is placed, you touch most of those files. OrderService grows to 1,500 lines because every order feature goes through it. And a change for one feature risks breaking another that happens to share a method.
Slice by use case instead
Vertical slice architecture groups code by what it does for the user. Each slice contains everything one use case needs, from the endpoint to the database:
Features/
Orders/
PlaceOrder.cs
CancelOrder.cs
GetOrderDetails.cs
ListOrdersForCustomer.cs
Shipping/
ShipOrder.cs
A slice can be a single file:
public static class PlaceOrder
{
public record Request(Guid CustomerId, List<LineItem> Items);
public record Response(Guid OrderId, decimal Total);
public static void Map(IEndpointRouteBuilder app) =>
app.MapPost("/orders", Handle);
private static async Task<IResult> Handle(Request request, OrdersDb db, CancellationToken ct)
{
if (request.Items.Count == 0)
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["items"] = ["An order needs at least one item."]
});
var order = Order.Create(request.CustomerId, request.Items);
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return Results.Created($"/orders/{order.Id}", new Response(order.Id, order.Total));
}
}
Why it works
- Changes stay local. A new requirement for placing orders changes
PlaceOrder.csand nothing else. - Each slice can be as simple or complex as it needs. A read-only list can project straight from
DbContextto a DTO. A complex command can use a rich domain model. No layer forces every feature through the same ceremony. - Less abstraction for its own sake. No
IOrderServicewith one implementation and no repository that only wrapsDbContext. - Easier to delete. Removing a feature means removing a file or folder.
What's still shared
Slices aren't a ban on sharing. The domain model, such as the Order entity and its rules, is shared. So are infrastructure concerns: the DbContext, authentication, logging, validation and error handling. What you avoid is a shared service layer that every feature has to squeeze through.
When two slices really do need the same logic, extract it then, into the domain or a small helper. Don't start with the abstraction.
Do you need MediatR?
Many vertical slice examples send each request through a mediator library. It's not required. Minimal API endpoints or controller actions calling a handler directly are a perfectly good slice. Add a mediator only if you want its pipeline behaviors and are happy with the dependency.
Takeaway
Group code by feature, so one use case lives in one place. Share the domain model and infrastructure, not a service layer, and extract common code only when duplication actually appears.