Skip to main content

Vertical Slice Architecture in .NET: A Complete Guide

Learn vertical slice architecture in .NET with C# examples. Organize code by feature, not layer — with MediatR, minimal APIs, and best practices. Start now!

What Is Vertical Slice Architecture in .NET?

Vertical slice architecture is a way of organizing your .NET codebase by feature instead of by technical layer. Rather than spreading a single feature across a Controllers folder, a Services folder, a Repositories folder, and a DTOs folder, you put everything that feature needs — the endpoint, the request, the handler, the validation, and the data access — into one folder, one "slice." When you need to change how "Create Order" works, you open one folder, not five projects.

The term was popularized by Jimmy Bogard (creator of MediatR and AutoMapper), and it has become one of the most talked-about approaches to ASP.NET Core project structure. In this guide, you'll learn why vertical slice architecture is winning over teams that used to swear by n-tier and Clean Architecture, how to implement it with minimal APIs and MediatR, and the pitfalls to avoid before you restructure your solution.

Why Layered Architecture Falls Short

The classic layered (or "onion") solution looks tidy on day one:

  • MyApp.Api — controllers
  • MyApp.Application — services, interfaces, DTOs
  • MyApp.Domain — entities
  • MyApp.Infrastructure — repositories, EF Core

The problem is that almost no change you ever make is confined to one layer. Adding a field to "Update Customer" means touching the controller, the DTO, the mapping profile, the service interface, the service implementation, the repository interface, the repository, and the entity. Eight files across four projects for one business change. That's high coupling across layers disguised as low coupling within them.

Worse, layered architectures push you toward shared abstractions — the ICustomerService with 27 methods, the generic repository that every feature must squeeze through. Over time, changing anything risks breaking everything, because everything flows through the same choke points.

Vertical slice architecture flips the axis. Coupling is high inside a slice (which is fine — that code changes together) and low between slices (which is what actually protects you). This is the classic principle: maximize cohesion, minimize coupling — applied to how requests flow through your system.

Vertical Slice Architecture vs Clean Architecture

This is the comparison most developers search for, so let's be direct. Clean Architecture and vertical slice architecture are not enemies — they answer different questions.

  • Clean Architecture answers: "In which direction should dependencies point?" (Inward, toward the domain.)
  • Vertical slice architecture answers: "How should I group code that changes together?" (By feature.)

Many successful teams combine them: slices for organization, with a shared domain model at the core for genuinely shared business rules. Where they conflict is ceremony. Clean Architecture, applied dogmatically, demands interfaces and mapping at every boundary even when a feature is a simple read. Vertical slices let each feature choose its own weight: a dashboard query can hit the database directly with Dapper, while a complex "Place Order" command can use a rich domain model — in the same application, without hypocrisy.

Project Structure: Organizing by Feature

Here's a typical feature-folder layout for an ASP.NET Core application using vertical slices:

src/
  MyShop.Api/
    Features/
      Orders/
        CreateOrder.cs      // request, validator, handler, endpoint
        GetOrder.cs
        CancelOrder.cs
        OrdersModule.cs     // endpoint registration
      Products/
        SearchProducts.cs
        CreateProduct.cs
    Common/
      Behaviors/            // cross-cutting: validation, logging
      Persistence/          // DbContext
    Program.cs

Notice what's missing: no Services folder, no Repositories folder, no DTOs folder. Each file is a complete, self-contained use case. New developers can find "the code that cancels an order" in seconds — it's in Features/Orders/CancelOrder.cs.

Building a Vertical Slice with MediatR and Minimal APIs

Let's build a complete, runnable slice. First, the packages:

// dotnet add package MediatR
// dotnet add package FluentValidation.DependencyInjectionExtensions
// dotnet add package Microsoft.EntityFrameworkCore.Sqlite

Here is the entire "Create Order" feature in one file. The request, validator, handler, and response live together as nested types inside a static class named after the feature:

using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;

namespace MyShop.Api.Features.Orders;

public static class CreateOrder
{
    // The request — what the client sends
    public record Command(string CustomerEmail, List<LineItem> Items)
        : IRequest<Response>;

    public record LineItem(int ProductId, int Quantity);

    // The response — what the client gets back
    public record Response(int OrderId, decimal Total);

    // Validation lives with the feature, not in a global folder
    public class Validator : AbstractValidator<Command>
    {
        public Validator()
        {
            RuleFor(x => x.CustomerEmail).NotEmpty().EmailAddress();
            RuleFor(x => x.Items).NotEmpty();
            RuleForEach(x => x.Items)
                .Must(i => i.Quantity > 0)
                .WithMessage("Quantity must be positive.");
        }
    }

    // The handler — the actual business logic
    public class Handler(ShopDbContext db) : IRequestHandler<Command, Response>
    {
        public async Task<Response> Handle(
            Command request, CancellationToken ct)
        {
            var productIds = request.Items.Select(i => i.ProductId).ToList();
            var products = await db.Products
                .Where(p => productIds.Contains(p.Id))
                .ToDictionaryAsync(p => p.Id, ct);

            var order = new Order { CustomerEmail = request.CustomerEmail };
            foreach (var item in request.Items)
            {
                if (!products.TryGetValue(item.ProductId, out var product))
                    throw new ProductNotFoundException(item.ProductId);

                order.AddLine(product, item.Quantity);
            }

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

            return new Response(order.Id, order.Total);
        }
    }
}

The minimal API endpoint maps straight to the slice:

public static class OrdersModule
{
    public static void MapOrderEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/orders").WithTags("Orders");

        group.MapPost("/", async (
            CreateOrder.Command command, ISender sender, CancellationToken ct) =>
        {
            var response = await sender.Send(command, ct);
            return Results.Created($"/api/orders/{response.OrderId}", response);
        });

        group.MapGet("/{id:int}", async (
            int id, ISender sender, CancellationToken ct) =>
        {
            var response = await sender.Send(new GetOrder.Query(id), ct);
            return response is null ? Results.NotFound() : Results.Ok(response);
        });
    }
}

And Program.cs wires it all together:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<ShopDbContext>(o =>
    o.UseSqlite("Data Source=shop.db"));

builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
    cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
});

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

var app = builder.Build();
app.MapOrderEndpoints();
app.Run();

Cross-Cutting Concerns with Pipeline Behaviors

The question skeptics always ask: "Without layers, where does validation, logging, and transaction handling go?" The answer is MediatR pipeline behaviors — middleware for your handlers. Write it once, and every slice gets it automatically:

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)
    {
        var failures = validators
            .Select(v => v.Validate(request))
            .SelectMany(r => r.Errors)
            .Where(f => f is not null)
            .ToList();

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

        return await next();
    }
}

This is the key insight: vertical slice architecture doesn't abandon separation of concerns — it separates cross-cutting concerns (validation, logging, auth) into behaviors while keeping feature concerns together.

Queries Can Be Simple — And That's the Point

Reads rarely need a domain model. In a slice, a query is free to be as thin as it wants:

public static class GetOrder
{
    public record Query(int Id) : IRequest<Response?>;

    public record Response(int Id, string CustomerEmail, decimal Total);

    public class Handler(ShopDbContext db) : IRequestHandler<Query, Response?>
    {
        public async Task<Response?> Handle(Query request, CancellationToken ct)
            => await db.Orders
                .Where(o => o.Id == request.Id)
                .Select(o => new Response(o.Id, o.CustomerEmail, o.Total))
                .FirstOrDefaultAsync(ct);
    }
}

No repository, no service, no AutoMapper — a projection straight to the response. This natural split between commands and queries means vertical slices give you lightweight CQRS for free, without event sourcing or separate databases.

Best Practices for Vertical Slice Architecture

  • One file per slice until it hurts. Nested types inside a static class keep everything discoverable. Split into a subfolder only when a slice genuinely outgrows a file.
  • Push shared logic down, not sideways. When two slices need the same rule, move it into a domain entity method (like order.AddLine() above) — not into a shared "service" that recouples your slices.
  • Test the whole slice. The best test for a slice is an integration test that sends the command through MediatR against a real (test-container or SQLite) database. You test behavior, not mocks.
  • Use behaviors for anything you'd repeat in three or more handlers — validation, logging, idempotency, transactions.
  • You don't need MediatR. Since MediatR moved to a commercial license, many teams use plain handler classes registered in DI, or libraries like Wolverine or FastEndpoints. The architecture is about folder-by-feature and self-contained use cases, not any specific package.

Common Pitfalls to Avoid

  • Copy-paste drift. Slices tolerate some duplication — but business rules must not be duplicated. If a rounding rule exists in two handlers, it will eventually diverge. Duplicate structure, never rules.
  • Slices calling slices. Sending a command from inside another handler rebuilds the spaghetti you left layers to escape. Extract shared domain logic instead, or raise a domain event.
  • Fat "Common" folders. If your Common folder grows faster than your Features folder, you've rebuilt a layered architecture with extra steps.
  • Restructuring a working app for fashion. If your layered codebase is healthy and the team ships fast, don't migrate. Adopt slices for new features first and let the styles coexist.

Conclusion: Should You Use Vertical Slice Architecture?

Vertical slice architecture aligns your codebase with how software actually changes: feature by feature, not layer by layer. By organizing code by feature, each use case becomes a self-contained unit you can read, test, modify, and delete without archaeology across five projects. Combined with minimal APIs, a mediator pipeline for cross-cutting concerns, and a shared domain model for genuinely shared rules, it scales from small APIs to large modular monoliths — and it's a natural stepping stone to microservices if you ever need them.

Key takeaways:

  • Organize by feature, not layer — code that changes together should live together.
  • Each slice chooses its own complexity: thin Dapper/EF projections for reads, rich domain logic for writes.
  • Handle cross-cutting concerns once with pipeline behaviors, not per-layer boilerplate.
  • Share business rules through the domain model; never let slices call each other.
  • Vertical slice architecture and Clean Architecture answer different questions — you can combine them.

Start small: build your next feature as a single slice in a Features folder. Once you've changed a requirement by editing one file instead of eight, you'll understand why so many .NET teams aren't going back.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...