Organize code by feature, not by layer

ยท 2 min read

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.cs and nothing else.
  • Each slice can be as simple or complex as it needs. A read-only list can project straight from DbContext to 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 IOrderService with one implementation and no repository that only wraps DbContext.
  • 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.