Vertical Slice Architecture on .NET 10 — Organize Code by Feature

Posted on: 4/23/2026 6:14:23 AM

The Problem with Traditional Layered Architecture

If you've worked on large .NET projects, you're familiar with N-Layer architecture: Controller → Service → Repository → Database. This model is intuitive, easy to understand, and practically the default for every ASP.NET Core project. But as your project grows to hundreds of endpoints, cracks start to appear.

70% Feature changes require modifying 3+ files
5-8 Layers traversed per simple request
40% Service layer code is pure pass-through

With Clean Architecture or Onion Architecture, you organize code by horizontal layers — all Controllers in one folder, all Services in another, all Repositories elsewhere. Adding a "Create Order" feature means creating or modifying files across 3-4 different directories.

graph TB
    subgraph "Traditional Layered Architecture"
        A["Controllers/"] --> B["OrderController"]
        A --> C["ProductController"]
        A --> D["UserController"]
        E["Services/"] --> F["OrderService"]
        E --> G["ProductService"]
        E --> H["UserService"]
        I["Repositories/"] --> J["OrderRepository"]
        I --> K["ProductRepository"]
        I --> L["UserRepository"]
    end

    style A fill:#e94560,stroke:#fff,color:#fff
    style E fill:#e94560,stroke:#fff,color:#fff
    style I fill:#e94560,stroke:#fff,color:#fff
    style B fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
    style C fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
    style D fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
    style F fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
    style G fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
    style H fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
    style J fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
    style K fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
    style L fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50

Layered architecture — code scattered by layer, not by feature

The consequence? When 5 developers work on 5 different features, they all modify the same Services/ and Repositories/ folders, causing constant merge conflicts. This is horizontal coupling — unrelated features sharing the same code space.

What is Vertical Slice Architecture?

Vertical Slice Architecture (VSA) completely inverts how you organize code: instead of grouping by layer, you group by feature. Each feature is a "vertical slice" cutting through from request to database, self-containing all necessary logic.

Core Principle

Each use case (create order, list products, update profile...) is an independent slice with its own request model, handler, validation, and response model. No shared abstractions unless genuinely needed.

graph TB
    subgraph "Vertical Slice Architecture"
        subgraph "Feature: CreateOrder"
            A1["Request"] --> A2["Validator"]
            A2 --> A3["Handler"]
            A3 --> A4["DB Access"]
        end
        subgraph "Feature: GetProducts"
            B1["Request"] --> B2["Handler"]
            B2 --> B3["DB Access"]
        end
        subgraph "Feature: UpdateProfile"
            C1["Request"] --> C2["Validator"]
            C2 --> C3["Handler"]
            C3 --> C4["DB Access"]
        end
    end

    style A1 fill:#e94560,stroke:#fff,color:#fff
    style A2 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style A3 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style A4 fill:#2c3e50,stroke:#fff,color:#fff
    style B1 fill:#4CAF50,stroke:#fff,color:#fff
    style B2 fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
    style B3 fill:#2c3e50,stroke:#fff,color:#fff
    style C1 fill:#ff9800,stroke:#fff,color:#fff
    style C2 fill:#f8f9fa,stroke:#ff9800,color:#2c3e50
    style C3 fill:#f8f9fa,stroke:#ff9800,color:#2c3e50
    style C4 fill:#2c3e50,stroke:#fff,color:#fff

Vertical Slice — each feature is a self-contained, independent unit

This concept was popularized by Jimmy Bogard (creator of MediatR, AutoMapper) since 2018, but it took until 2025-2026 with the maturation of Minimal APIs in .NET and supporting libraries for VSA to truly explode in the .NET community.

Architecture Comparison: VSA vs Clean Architecture

Criteria Clean Architecture Vertical Slice
Code organization By layer (Controllers, Services, Repos) By feature (CreateOrder, GetProducts...)
Coupling Horizontal coupling across features in same layer Each slice independent, minimal coupling
Adding features Create/modify 3-5 files across folders Add 1 file/folder
Removing features Hunt and delete across layers Delete 1 file/folder
Testing Unit test each layer, heavy mocking Integration test per use case, minimal mocking
Merge conflicts High — multiple devs modify same folders Low — each dev works on separate features
Best for Complex domains, large teams needing standards API-first, CRUD+logic, teams valuing velocity

Real-World Folder Structure on .NET 10

src/MyApp.Api/
├── Features/
│ ├── Orders/
│ │ ├── CreateOrder.cs
│ │ ├── GetOrderById.cs
│ │ ├── ListOrders.cs
│ │ ├── CancelOrder.cs
│ │ └── OrderMappings.cs
│ ├── Products/
│ │ ├── CreateProduct.cs
│ │ ├── GetProductById.cs
│ │ ├── SearchProducts.cs
│ │ └── UpdateProduct.cs
│ └── Users/
│ ├── Register.cs
│ ├── Login.cs
│ ├── GetProfile.cs
│ └── UpdateProfile.cs
├── Shared/
│ ├── AppDbContext.cs
│ ├── PipelineBehaviors/
│ └── Extensions/
├── Program.cs
└── appsettings.json

Golden Rule

Each file in Features/ contains the complete flow: Request → Validator → Handler → Response → Endpoint for a single use case. The Shared/ folder only contains truly shared infrastructure (DbContext, middleware, pipeline behaviors).

Implementation with MediatR + Minimal API

The most popular combo for VSA on .NET 10 is MediatR (mediator pattern) + Minimal API (replacing Controllers). MediatR acts as the dispatcher — receiving requests, finding the matching handler, and returning responses.

Package Installation

dotnet add package MediatR
dotnet add package FluentValidation
dotnet add package FluentValidation.DependencyInjectionExtensions
dotnet add package Carter

Example: CreateOrder Feature

The entire "Create Order" use case lives in a single file:

// Features/Orders/CreateOrder.cs
using Carter;
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;

namespace MyApp.Features.Orders;

// 1. Request & Response
public record CreateOrderRequest(
    int CustomerId,
    List<OrderItemDto> Items,
    string? Note
) : IRequest<CreateOrderResponse>;

public record OrderItemDto(int ProductId, int Quantity);

public record CreateOrderResponse(
    int OrderId,
    decimal TotalAmount,
    DateTime CreatedAt
);

// 2. Validation
public class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
    public CreateOrderValidator()
    {
        RuleFor(x => x.CustomerId).GreaterThan(0);
        RuleFor(x => x.Items).NotEmpty()
            .WithMessage("Order must contain at least 1 item");
        RuleForEach(x => x.Items).ChildRules(item =>
        {
            item.RuleFor(i => i.ProductId).GreaterThan(0);
            item.RuleFor(i => i.Quantity).InclusiveBetween(1, 100);
        });
    }
}

// 3. Handler — all business logic
public class CreateOrderHandler(AppDbContext db)
    : IRequestHandler<CreateOrderRequest, CreateOrderResponse>
{
    public async Task<CreateOrderResponse> Handle(
        CreateOrderRequest request,
        CancellationToken ct)
    {
        var products = await db.Products
            .Where(p => request.Items.Select(i => i.ProductId).Contains(p.Id))
            .ToDictionaryAsync(p => p.Id, ct);

        var order = new Order
        {
            CustomerId = request.CustomerId,
            Note = request.Note,
            Items = request.Items.Select(i => new OrderItem
            {
                ProductId = i.ProductId,
                Quantity = i.Quantity,
                UnitPrice = products[i.ProductId].Price
            }).ToList()
        };

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

        return new CreateOrderResponse(
            order.Id,
            order.Items.Sum(i => i.UnitPrice * i.Quantity),
            order.CreatedAt
        );
    }
}

// 4. Endpoint — Minimal API via Carter
public class CreateOrderEndpoint : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        app.MapPost("/api/orders", async (
            CreateOrderRequest request,
            ISender sender) =>
        {
            var result = await sender.Send(request);
            return Results.Created($"/api/orders/{result.OrderId}", result);
        })
        .WithName("CreateOrder")
        .WithTags("Orders")
        .Produces<CreateOrderResponse>(201)
        .ProducesValidationProblem();
    }
}

Structure Analysis

All 4 components (Request/Response models, Validation, Business logic, Endpoint routing) live in a single file. When you need to modify order creation logic, you open exactly CreateOrder.cs — no jumping between Controller, Service, and Repository.

Example: GetOrderById (Query)

// Features/Orders/GetOrderById.cs
namespace MyApp.Features.Orders;

public record GetOrderByIdRequest(int OrderId) : IRequest<OrderDetailResponse?>;

public record OrderDetailResponse(
    int Id,
    string CustomerName,
    List<OrderItemResponse> Items,
    decimal Total,
    string Status,
    DateTime CreatedAt
);

public record OrderItemResponse(
    string ProductName,
    int Quantity,
    decimal UnitPrice,
    decimal Subtotal
);

public class GetOrderByIdHandler(AppDbContext db)
    : IRequestHandler<GetOrderByIdRequest, OrderDetailResponse?>
{
    public async Task<OrderDetailResponse?> Handle(
        GetOrderByIdRequest request,
        CancellationToken ct)
    {
        return await db.Orders
            .Where(o => o.Id == request.OrderId)
            .Select(o => new OrderDetailResponse(
                o.Id,
                o.Customer.Name,
                o.Items.Select(i => new OrderItemResponse(
                    i.Product.Name,
                    i.Quantity,
                    i.UnitPrice,
                    i.Quantity * i.UnitPrice
                )).ToList(),
                o.Items.Sum(i => i.Quantity * i.UnitPrice),
                o.Status.ToString(),
                o.CreatedAt
            ))
            .FirstOrDefaultAsync(ct);
    }
}

public class GetOrderByIdEndpoint : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        app.MapGet("/api/orders/{orderId:int}", async (
            int orderId,
            ISender sender) =>
        {
            var result = await sender.Send(new GetOrderByIdRequest(orderId));
            return result is not null
                ? Results.Ok(result)
                : Results.NotFound();
        })
        .WithName("GetOrderById")
        .WithTags("Orders");
    }
}

Pipeline Behaviors — Cross-Cutting Concerns

A common question: "If each slice is independent, how do you handle cross-cutting concerns like validation, logging, caching?" The answer is MediatR's Pipeline Behaviors — they act like middleware for each request.

graph LR
    A["HTTP Request"] --> B["Logging Behavior"]
    B --> C["Validation Behavior"]
    C --> D["Caching Behavior"]
    D --> E["Handler"]
    E --> F["Response"]

    style A fill:#2c3e50,stroke:#fff,color:#fff
    style B fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style C fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style D fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style E fill:#e94560,stroke:#fff,color:#fff
    style F fill:#4CAF50,stroke:#fff,color:#fff

Pipeline Behaviors — middleware pattern for MediatR requests

Automatic Validation Behavior

// Shared/PipelineBehaviors/ValidationBehavior.cs
public class ValidationBehavior<TRequest, TResponse>(
    IEnumerable<IValidator<TRequest>> validators)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        if (!validators.Any()) return await next(ct);

        var context = new ValidationContext<TRequest>(request);

        var failures = (await Task.WhenAll(
                validators.Select(v => v.ValidateAsync(context, ct))))
            .SelectMany(r => r.Errors)
            .Where(f => f is not null)
            .ToList();

        if (failures.Count != 0)
            throw new ValidationException(failures);

        return await next(ct);
    }
}

Logging Behavior

// Shared/PipelineBehaviors/LoggingBehavior.cs
public class LoggingBehavior<TRequest, TResponse>(
    ILogger<LoggingBehavior<TRequest, TResponse>> logger)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        var requestName = typeof(TRequest).Name;
        logger.LogInformation("Handling {RequestName}", requestName);

        var sw = Stopwatch.StartNew();
        var response = await next(ct);
        sw.Stop();

        logger.LogInformation(
            "Handled {RequestName} in {ElapsedMs}ms",
            requestName, sw.ElapsedMilliseconds);

        return response;
    }
}

Registration in Program.cs

// Program.cs
var builder = WebApplication.CreateBuilder(args);

// EF Core
builder.Services.AddDbContext<AppDbContext>(opt =>
    opt.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

// MediatR + Pipeline Behaviors
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
    cfg.AddBehavior(typeof(IPipelineBehavior<,>),
        typeof(LoggingBehavior<,>));
    cfg.AddBehavior(typeof(IPipelineBehavior<,>),
        typeof(ValidationBehavior<,>));
});

// FluentValidation
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);

// Carter (auto-discover endpoints)
builder.Services.AddCarter();

var app = builder.Build();

app.MapCarter(); // Auto-registers all ICarterModule implementations

app.Run();

Carter Auto-Scan

MapCarter() automatically scans the assembly for all classes implementing ICarterModule and registers their endpoints. No manual app.MapGet/MapPost in Program.cs — each feature registers its own routes.

When NOT to Use Vertical Slice

VSA is not a silver bullet. There are cases where traditional layered or Clean Architecture remains the better fit:

Scenario Use VSA? Reason
API with 50+ endpoints, 70% CRUD Yes Each endpoint is a simple slice, easy to maintain
Complex domain with interlinked business rules Consider Domain logic needs DDD, aggregate roots — can be combined
New team needing code style standardization No Clean Architecture provides clearer guidelines
Small single-purpose microservice Ideal Few slices per service, very clean
Monolith gradually migrating to microservices Yes Each feature group can be extracted into a separate service later

Common Pitfall

Don't abstract too early. When starting out, two handlers may have similar logic — let them duplicate. Only when a pattern repeats 3+ times AND the logic is truly identical should you extract a shared service. Premature abstraction in VSA turns it back into layered architecture.

VSA Combined with CQRS

VSA combines naturally with CQRS (Command Query Responsibility Segregation). In practice, each slice is already a separate command or query:

graph TB
    subgraph "Commands (Write)"
        C1["CreateOrder"]
        C2["CancelOrder"]
        C3["UpdateProduct"]
    end
    subgraph "Queries (Read)"
        Q1["GetOrderById"]
        Q2["ListOrders"]
        Q3["SearchProducts"]
    end

    C1 --> DB1["Write DB"]
    C2 --> DB1
    C3 --> DB1
    Q1 --> DB2["Read Replica"]
    Q2 --> DB2
    Q3 --> DB2

    style C1 fill:#e94560,stroke:#fff,color:#fff
    style C2 fill:#e94560,stroke:#fff,color:#fff
    style C3 fill:#e94560,stroke:#fff,color:#fff
    style Q1 fill:#4CAF50,stroke:#fff,color:#fff
    style Q2 fill:#4CAF50,stroke:#fff,color:#fff
    style Q3 fill:#4CAF50,stroke:#fff,color:#fff
    style DB1 fill:#2c3e50,stroke:#fff,color:#fff
    style DB2 fill:#2c3e50,stroke:#fff,color:#fff

VSA + CQRS — commands and queries naturally separate into individual slices

With this approach, query handlers can use Dapper or raw SQL for optimal performance, while command handlers stick with EF Core and full change tracking. Each slice decides its own data access strategy.

// Features/Orders/ListOrders.cs — Query using Dapper
public class ListOrdersHandler(IDbConnection db)
    : IRequestHandler<ListOrdersRequest, PagedResult<OrderSummary>>
{
    public async Task<PagedResult<OrderSummary>> Handle(
        ListOrdersRequest request,
        CancellationToken ct)
    {
        const string sql = """
            SELECT o.Id, c.Name AS CustomerName,
                   COUNT(oi.Id) AS ItemCount,
                   SUM(oi.Quantity * oi.UnitPrice) AS Total,
                   o.Status, o.CreatedAt
            FROM Orders o
            JOIN Customers c ON o.CustomerId = c.Id
            LEFT JOIN OrderItems oi ON o.Id = oi.OrderId
            WHERE (@Status IS NULL OR o.Status = @Status)
            GROUP BY o.Id, c.Name, o.Status, o.CreatedAt
            ORDER BY o.CreatedAt DESC
            OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY
            """;

        var results = await db.QueryAsync<OrderSummary>(sql, new
        {
            request.Status,
            Skip = (request.Page - 1) * request.PageSize,
            Take = request.PageSize
        });

        return new PagedResult<OrderSummary>(results.ToList(), totalCount);
    }
}

Testing in VSA

A major advantage of VSA is that testing becomes simpler and more meaningful. Instead of unit testing each layer separately with dozens of mocks, you write integration tests per slice:

// Tests/Features/Orders/CreateOrderTests.cs
public class CreateOrderTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task CreateOrder_WithValidItems_ReturnsCreated()
    {
        // Arrange
        var client = factory.CreateClient();
        var request = new CreateOrderRequest(
            CustomerId: 1,
            Items: [new(ProductId: 1, Quantity: 2)],
            Note: "Morning delivery"
        );

        // Act
        var response = await client.PostAsJsonAsync("/api/orders", request);

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.Created);

        var result = await response.Content
            .ReadFromJsonAsync<CreateOrderResponse>();
        result!.OrderId.Should().BeGreaterThan(0);
        result.TotalAmount.Should().BeGreaterThan(0);
    }

    [Fact]
    public async Task CreateOrder_WithEmptyItems_ReturnsBadRequest()
    {
        var client = factory.CreateClient();
        var request = new CreateOrderRequest(
            CustomerId: 1, Items: [], Note: null);

        var response = await client.PostAsJsonAsync("/api/orders", request);

        response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
    }
}

Test Behavior, Not Implementation

Integration tests verify "valid request in → correct response out" rather than "Service calls the right Repository method." When you refactor internal code, tests still pass as long as behavior doesn't change. These are tests with real value.

Migrating from Clean Architecture to VSA

If your project currently uses Clean Architecture and you want to transition to VSA, here's a practical roadmap:

Phase 1 — New Features Use VSA
Keep existing code as-is. All new endpoints follow the VSA pattern (file-per-feature). Both architectures coexist in one project — completely feasible since MediatR handlers and Controller/Service don't conflict.
Phase 2 — Migrate Hot Endpoints
Pick the 5-10 most frequently modified endpoints, convert from Controller+Service+Repository to a single VSA file. Ensure integration tests cover them before migrating.
Phase 3 — Clean Up Service Layer
Once enough endpoints are migrated, service classes will only have 1-2 methods left. Delete unused services/repositories, move DbContext access directly into handlers.
Phase 4 — Remove Controllers
Convert all endpoints from Controllers to Minimal API + Carter. Delete Controllers/, Services/, Repositories/ folders. The project now only has Features/ + Shared/.

Supporting Libraries for VSA on .NET 10

Library Role Notes
MediatR Mediator / CQRS dispatcher De facto standard, powerful pipeline behaviors
Carter Auto-discovery for Minimal API Replaces Controllers, auto-scans endpoint modules
FluentValidation Request validation Integrates well with MediatR via pipeline behavior
Wolverine MediatR replacement + message bus All-in-one: mediator + message queue + saga
FastEndpoints VSA-style endpoint framework No MediatR needed, provides request/handler/validator built-in
Immediate.Handlers Source-generated mediator Zero reflection, AOT-friendly, faster than MediatR

FastEndpoints — MediatR Alternative

If you prefer not to use MediatR, FastEndpoints is a framework designed specifically for the VSA pattern on .NET. It provides request binding, validation, and endpoint routing in a single package:

// Features/Orders/CreateOrder.cs — with FastEndpoints
public class CreateOrderEndpoint
    : Endpoint<CreateOrderRequest, CreateOrderResponse>
{
    public override void Configure()
    {
        Post("/api/orders");
        AllowAnonymous();
    }

    public override async Task HandleAsync(
        CreateOrderRequest req,
        CancellationToken ct)
    {
        var order = new Order
        {
            CustomerId = req.CustomerId,
            Items = req.Items.Select(i => new OrderItem
            {
                ProductId = i.ProductId,
                Quantity = i.Quantity
            }).ToList()
        };

        await DbContext.Orders.AddAsync(order, ct);
        await DbContext.SaveChangesAsync(ct);

        await SendCreatedAtAsync<GetOrderByIdEndpoint>(
            new { order.Id },
            new CreateOrderResponse(order.Id, order.Total, order.CreatedAt),
            cancellation: ct);
    }
}

Conclusion

Vertical Slice Architecture isn't a "replacement" for Clean Architecture — it's a different approach for a different class of problems. When your project is primarily API endpoints with moderately complex business logic, VSA helps you:

  • Reduce coupling — each feature is independent, changing one doesn't affect others
  • Accelerate development — adding a new feature means creating 1 file
  • Minimize merge conflicts — developers work on separate features, never touching each other's code
  • Write meaningful tests — integration tests per use case instead of unit tests per layer
  • Ease onboarding — new developers read 1 file to fully understand a feature

With the maturation of Minimal APIs on .NET 10, combined with MediatR (or FastEndpoints, Wolverine), implementing VSA has never been easier. Start by applying it to your next new feature — no full rewrite required.

References