.NET & C# Development · Lesson 57 of 92

Vertical Slice — One Feature, One Folder, Zero Friction

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.