Back to blog
Backend Systemsintermediate

Vertical Slice Architecture — Organize by Feature, Not Layer

Ditch horizontal layers. Structure your ASP.NET Core app by feature using vertical slices — one folder per use case, command/query/handler/validator together, less ceremony than Clean Architecture.

LearnixoApril 14, 20264 min read
.NETC#ArchitectureMediatRVertical SliceClean ArchitectureASP.NET Core
Share:𝕏

The Problem With Horizontal Layers

Classic layered architecture groups files by technical role:

src/
  Controllers/
    OrdersController.cs
    ProductsController.cs
  Services/
    OrderService.cs
    ProductService.cs
  Repositories/
    OrderRepository.cs
    ProductRepository.cs
  Models/
    Order.cs
    Product.cs

To add a "place order" feature you touch four folders. To understand it you jump between four files. Layers create coupling between unrelated features: OrderService and ProductService live in the same layer, sharing visibility into each other's internals even though they have no business relationship.


Vertical Slices: One Folder Per Feature

Vertical Slice Architecture (VSA) groups everything for one use case together:

src/
  Features/
    Orders/
      PlaceOrder/
        PlaceOrderCommand.cs
        PlaceOrderHandler.cs
        PlaceOrderValidator.cs
        PlaceOrderResponse.cs
      GetOrders/
        GetOrdersQuery.cs
        GetOrdersHandler.cs
        GetOrdersResponse.cs
      CancelOrder/
        CancelOrderCommand.cs
        CancelOrderHandler.cs
    Products/
      CreateProduct/
        ...
      GetProductById/
        ...
  Shared/
    Domain/
      Order.cs
      Product.cs
    Persistence/
      AppDbContext.cs
  Program.cs

Now adding a feature means adding a folder. Reading a feature means reading one folder.


A Complete Feature Slice

Everything for "place order" lives in Features/Orders/PlaceOrder/:

C#
// PlaceOrderCommand.cs
public record PlaceOrderCommand(
    Guid CustomerId,
    List<OrderLineDto> Lines) : IRequest<PlaceOrderResponse>;

public record OrderLineDto(Guid ProductId, int Quantity);
public record PlaceOrderResponse(Guid OrderId, decimal Total);
C#
// PlaceOrderValidator.cs
public class PlaceOrderValidator : AbstractValidator<PlaceOrderCommand>
{
    public PlaceOrderValidator()
    {
        RuleFor(x => x.CustomerId).NotEmpty();
        RuleFor(x => x.Lines).NotEmpty().WithMessage("Order must have at least one line.");
        RuleForEach(x => x.Lines).ChildRules(line =>
        {
            line.RuleFor(l => l.ProductId).NotEmpty();
            line.RuleFor(l => l.Quantity).GreaterThan(0);
        });
    }
}
C#
// PlaceOrderHandler.cs
public class PlaceOrderHandler : IRequestHandler<PlaceOrderCommand, PlaceOrderResponse>
{
    private readonly AppDbContext _db;

    public PlaceOrderHandler(AppDbContext db) => _db = db;

    public async Task<PlaceOrderResponse> Handle(
        PlaceOrderCommand request,
        CancellationToken ct)
    {
        var productIds = request.Lines.Select(l => l.ProductId).ToList();

        var products = await _db.Products
            .Where(p => productIds.Contains(p.Id))
            .ToDictionaryAsync(p => p.Id, ct);

        var order = new Order
        {
            Id = Guid.NewGuid(),
            CustomerId = request.CustomerId,
            CreatedAt = DateTime.UtcNow,
            Status = OrderStatus.Pending,
            Lines = request.Lines.Select(l => new OrderLine
            {
                ProductId = l.ProductId,
                Quantity = l.Quantity,
                UnitPrice = products[l.ProductId].Price
            }).ToList()
        };

        _db.Orders.Add(order);
        await _db.SaveChangesAsync(ct);

        return new PlaceOrderResponse(order.Id, order.Lines.Sum(l => l.UnitPrice * l.Quantity));
    }
}

The endpoint wires it up:

C#
// Minimal API endpoint — could also live in a controller
app.MapPost("/orders", async (
    PlaceOrderCommand command,
    IMediator mediator,
    CancellationToken ct) =>
{
    var result = await mediator.Send(command, ct);
    return Results.Created($"/orders/{result.OrderId}", result);
})
.WithName("PlaceOrder")
.WithTags("Orders");

Cross-Slice Shared Code

Not everything belongs in a slice. True shared concerns go in Shared/:

  • Domain entities (Order, Product) — used by many slices
  • AppDbContext — infrastructure shared across all
  • Common response types (PagedResult<T>)
  • Extension methods and utilities

The rule: if it's used by exactly one slice, it lives in that slice. If two or more slices need it, it moves to Shared/.


Registering Features With MediatR

C#
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssemblyContaining<Program>());

builder.Services.AddValidatorsFromAssemblyContaining<Program>();

// Validation pipeline behavior — applies to every command/query
builder.Services.AddTransient(
    typeof(IPipelineBehavior<,>),
    typeof(ValidationBehavior<,>));

No service registrations per feature. MediatR scans and wires handlers automatically.


VSA vs Clean Architecture

| Concern | Clean Architecture | Vertical Slice | |---|---|---| | Folder structure | By layer (Domain, Application, Infrastructure) | By feature | | Ceremony | High — interfaces, abstractions, mapping layers | Low — handler talks directly to DbContext | | Coupling between features | Low — enforced by layer boundaries | Low — enforced by folder boundaries | | Onboarding a feature | Touch multiple projects/layers | One folder | | Testability | Good — interfaces everywhere | Good — handler is a plain class | | When it shines | Complex domain with rich business rules | CRUD-heavy apps, feature teams, rapid iteration |

VSA doesn't forbid abstractions — it just doesn't mandate them. If a handler has complex domain logic, extract it to a domain service. If you need to swap databases, add a repository interface. You add abstractions when you feel the pain, not upfront.


When to Choose VSA vs Clean Architecture

Choose VSA when:

  • Your app is primarily read/write operations without complex domain invariants
  • You want fast onboarding — new developers find the feature, they find everything
  • Feature teams own separate slices with minimal cross-team coordination
  • You're building an API-first backend without a rich domain model

Choose Clean Architecture when:

  • The domain has complex invariants, aggregates, and domain events
  • You need strict boundaries to prevent infrastructure concerns from leaking into domain logic
  • The team is large and layer-based separation aligns with team structure
  • You have a DDD-driven design with bounded contexts that map to separate projects

VSA and Clean Architecture are not mutually exclusive — many production codebases start with VSA slices that internally follow clean separation where complexity demands it.

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.