Updated on

Vertical Slice Architecture is a technique that helps us build maintainable applications by separating the application around features or “vertical slices”.

We’re going to learn how it differs from traditional architecture and its advantages over the traditional approach. Then, we’re going to use minimal APIs and EF Core to build a RESTful API that follows the vertical slice architecture.

To download the source code for this article, you can visit our GitHub repository.

Let’s start with a refresher on the traditional approach.

The Traditional Approach

Maintainability is one of the core tenets of software development. Our applications should be easily maintainable and adaptable to change.

One of the go-to approaches to attain maintainability is to achieve separation of concerns. The traditional approach to software architecture splits the application into separate layers to achieve this, thus forming the layered architecture.

Hence, with this approach, we have each layer addressing simple distinct concerns. The user interface is not concerned with how the business logic works, or how we persist the data.

Other variants like the Onion Architecture and Clean Architecture are also based on the same layered architecture approach.

Problems With the Layered Approach

The problem with following the layered approach through any of the variants like Clean, Onion, or the traditional N-tier architecture is that these do not scale well as the application grows in size and complexity.

The implementation of a single unit of functionality is split across different layers. Thus, we need to go into each of the layers to add a new feature, or to fix an existing bug.

We start by separating every layer to allow them to change independently of each other. However, all our changes are made across all the layers together.

Shared classes make it worse. An EventService or an IEventRepository serves every feature, so it grows with each one. And when we change it for one feature, how sure are we that the others still work?

This leads to situations where we are leaving the application more prone to bugs due to a new developer not being aware of certain service dependencies between the layers.

What Is Vertical Slice Architecture in .NET?

With vertical slices, we think of the application code in terms of features rather than the layer it sits in. We treat each feature as a vertical slice.

When building an application based on this approach, we consider every request as a distinct use case.

Everything that request needs sits together, often in one file: the input and output types, the handler with the logic, the endpoint and the database query.

A feature or slice covers all the layers of a traditional architecture design, and instead of coupling across a layer, we couple along the “slice”.

Because every feature within the application is a separate component, it can change independently.

Code that several slices need, such as the entities and the DbContext, lives in a small shared area. A slice uses it directly, with no service layer in between.

In .NET, the whole app is often one ASP.NET Core project with a Features folder.

The aim is to maximize coupling in the slice while minimizing coupling between the slices.

Jimmy Bogard, who described the style in his 2018 post Vertical Slice Architecture, puts it the same way: “Minimize coupling between slices, and maximize coupling in a slice.”

Since every request is its own slice, we break the requests into either a “query” or a “command”, and hence implement the CQRS (Command and Query Responsibility Segregation) pattern in the application.

Here are two features of the app we’re about to build, cut both ways:

Two panels. Organized by layer: three bands, endpoints, services and data access, each holding a reserve tickets chip and an availability chip, so each feature appears in all three layers. Organized by feature: two vertical bars, ReserveTickets.cs and GetEventAvailability.cs, each holding an endpoint, a handler, an EF Core query and its records, standing on a shared base with the Event entity and its rule, Result and TicketingDbContext.

In the layered version, reserving tickets touches three layers. In the sliced version, it’s one file standing on a thin base of shared code.

In essence, with vertical slices, to start developing an application, it’s okay to have a degree of duplication in the code, as long as the separation between the features is clear.

Advantages of Vertical Slice Architecture

Let’s see some of its advantages over the traditional architecture.

Reduction in Coupling

With vertical slices, we reduce the coupling between features. We can focus on a single slice while implementing a feature rather than different layers, thus improving maintainability.

External Interaction

Every slice is independent to interact with external resources. We can have different slices using distinct ways to persist data without interfering with each other.

In our app, one slice runs its rule through a rich entity, while a simple read queries the DbContext right in its endpoint.

Command and Query Responsibility Segregation

We have our application code split between the reads (queries) and the writes (commands). This segregation helps us to treat each feature as a distinct use case.

Feature Grouping

As we promote heavy coupling within a slice, we have the code for any part of the feature physically near each other. This leads to the easier implementation of a change request.

This also allows easier understanding of components for the new developers who might not completely understand the application yet.

With all that said and done, let’s see what the application code looks like following this architecture.

Solution Structure

We’re going to build a small API for a ticketing system, with two features to start: checking how many tickets an event has left, and reserving tickets. One business rule matters: an event can’t be sold beyond its capacity.

We need the .NET 10 SDK, whose CLI puts the noun first (dotnet solution add, dotnet package add). Let’s create a solution, a web project and the one package we need:

dotnet new sln -n EventTicketing
dotnet new web -n EventTicketing
dotnet solution add EventTicketing
dotnet package add Microsoft.EntityFrameworkCore.Sqlite --version 10.0.12 --project EventTicketing

dotnet new web gives us an empty project that uses minimal APIs, and the solution file comes out as EventTicketing.slnx.

We store the data in SQLite through EF Core. The --version flag matters: once EF Core 11 is out, the same command without it would pick version 11, which doesn’t support net10.0, and fail.

Let’s look at the project organization. The solution includes the Data folder that contains the DbContext and its seed data. The Domain folder contains the entity and its rule, and the Features folder contains the vertical slices.

Each subfolder inside the Features folder is a business area, and each file in it is a self-contained use case.

Domain and Data Setup

The domain classes are the base on which our application is built. Slices share very little, but they do share the business itself: the event, its rule and the errors the rule can return.

Running out of seats is normal business, so instead of throwing an exception, we return a result. Let’s create EventTicketing/Domain/Result.cs:

namespace EventTicketing.Domain;

public enum ErrorType
{
    NotFound,
    Conflict
}

public sealed record Error(string Code, string Description, ErrorType Type);

public class Result
{
    protected Result(Error? error) => Error = error;

    public Error? Error { get; }
    public bool IsSuccess => Error is null;

    public static Result Success() => new(null);

    public static implicit operator Result(Error error) => new(error);
}

public sealed class Result<TValue> : Result
{
    private readonly TValue? _value;

    private Result(TValue? value, Error? error)
        : base(error) => _value = value;

    public TValue Value => IsSuccess
        ? _value!
        : throw new InvalidOperationException("A failed result has no value.");

    public static implicit operator Result<TValue>(TValue value) =>
        new(value, null);

    public static implicit operator Result<TValue>(Error error) =>
        new(default, error);
}

A Result either succeeded, or it failed and carries an Error that says why. Thanks to the implicit operators, a handler can return its value or its Error, and C# builds the matching Result.

ErrorType tells the HTTP code which status to send, so the domain never has to know about HTTP. Our Result pattern in .NET Web API article takes the same type much further.

Next come the errors our event can return. Let’s create EventTicketing/Domain/EventErrors.cs:

namespace EventTicketing.Domain;

public static class EventErrors
{
    public static Error NotFound(int eventId) =>
        new("Event.NotFound", $"Event {eventId} was not found.", ErrorType.NotFound);

    public static Error SoldOut(int ticketsLeft, int requested) =>
        new("Event.SoldOut", $"Only {ticketsLeft} tickets left, {requested} requested.", ErrorType.Conflict);
}

Now, the entity class itself. Let’s create EventTicketing/Domain/Event.cs:

namespace EventTicketing.Domain;

public sealed class Event
{
    private Event(string name, int capacity)
    {
        Name = name;
        Capacity = capacity;
    }

    public int Id { get; private set; }
    public string Name { get; private set; }
    public int Capacity { get; private set; }
    public int TicketsSold { get; private set; }

    public int TicketsLeft => Capacity - TicketsSold;

    public static Event Create(string name, int capacity)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity);

        return new Event(name, capacity);
    }

    public Result Reserve(int quantity)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);

        if (quantity > TicketsLeft)
            return EventErrors.SoldOut(TicketsLeft, quantity);

        TicketsSold += quantity;

        return Result.Success();
    }
}

No setter is public, so Reserve() is the only way to sell tickets. It throws for zero tickets or fewer, since that’s a bug in the caller, and returns EventErrors.SoldOut when the event can’t cover the request.

Why does a sliced app need a rich entity? Because the overselling rule belongs to the event. Any slice that sells tickets, today or next year, has to go through Reserve().

Next, we need to take a look at our data-related classes. Our TicketingDbContext class inherits from DbContext. Let’s create EventTicketing/Data/TicketingDbContext.cs:

using EventTicketing.Domain;
using Microsoft.EntityFrameworkCore;

namespace EventTicketing.Data;

public sealed class TicketingDbContext(DbContextOptions<TicketingDbContext> options)
    : DbContext(options)
{
    public DbSet<Event> Events => Set<Event>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Event>(builder =>
        {
            builder.Property(e => e.Name).HasMaxLength(200);

            builder.HasData(
                new { Id = 1, Name = "Vertical Slice Live", Capacity = 100, TicketsSold = 0 },
                new { Id = 2, Name = "Tiny Jazz Club Night", Capacity = 2, TicketsSold = 0 });
        });
    }
}

We will use this class to query and save instances of our entity in the database.

The mapping lives here, so Event carries no EF Core attributes, and HasData() seeds two events: “Vertical Slice Live” (100 seats) and “Tiny Jazz Club Night” (only 2).

Where’s the repository? There isn’t one. EF Core’s DbContext already works as a repository and a unit of work, as its reference documentation says, so each slice queries it directly.

Our article on the unit of work pattern explains what a separate unit of work adds, and what it costs.

The last shared piece is the HTTP code every slice needs. Let’s create EventTicketing/ResultExtensions.cs:

using EventTicketing.Domain;

namespace EventTicketing;

public static class ResultExtensions
{
    public static IResult ToProblem(this Result result)
    {
        var error = result.Error
            ?? throw new InvalidOperationException("A successful result is not a problem.");

        var statusCode = error.Type switch
        {
            ErrorType.NotFound => StatusCodes.Status404NotFound,
            ErrorType.Conflict => StatusCodes.Status409Conflict,
            _ => StatusCodes.Status500InternalServerError
        };

        return TypedResults.Problem(statusCode: statusCode, title: error.Code, detail: error.Description);
    }
}

ToProblem() turns a failed Result into a problem details response: 404 for not found, 409 for a conflict, 500 for anything else. Our ProblemDetails in ASP.NET Core article covers that format.

Hence, our domain and data setup is complete. That’s five files, and none of them knows that a feature exists.

What Belongs in Shared Code?

Code goes into a shared folder when more than one slice needs it and it belongs to the business itself, like the event and its rule.

An EventService with a method per feature, or a service manager that hands one to every handler, fails that test. It’s a layer under another name.

Each of its methods belongs to one request, and the class ties together every feature it serves.

So what do we do when two slices need the same logic? If it’s a business rule, we move it onto the entity. If it’s plumbing, we keep a copy in each slice until the duplication starts to hurt.

Features

With all the other setup done, let’s look at the core of what makes up this architecture, i.e. features.

Each feature closely resembles how a user interacts with the system. So, instead of having the functionality separated by layers, the emphasis is on each separate feature being a separate component.

This minimizes the coupling between features and allows them to change independently.

One of the major differences between vertical slices and the traditional approach is that we create separate classes for each request instead of keeping them together inside one service class as a method.

Queries

A query class contains several members. We can classify them into the result (output), the handler, and the endpoint that receives the request (input).

Let’s write the GetEventAvailability class to get a better idea of how it works. Create EventTicketing/Features/Events/GetEventAvailability.cs:

using EventTicketing.Data;
using EventTicketing.Domain;
using Microsoft.EntityFrameworkCore;

namespace EventTicketing.Features.Events;

public static class GetEventAvailability
{
    public sealed record Response(int EventId, string Name, int TicketsLeft);

    public sealed class Handler(TicketingDbContext dbContext)
    {
        public async Task<Result<Response>> HandleAsync(
            int eventId, CancellationToken cancellationToken = default)
        {
            var availability = await dbContext.Events
                .AsNoTracking()
                .Where(e => e.Id == eventId)
                .Select(e => new Response(e.Id, e.Name, e.Capacity - e.TicketsSold))
                .FirstOrDefaultAsync(cancellationToken);

            if (availability is null)
                return EventErrors.NotFound(eventId);

            return availability;
        }
    }

    public static void MapEndpoint(IEndpointRouteBuilder app) =>
        app.MapGet("/{eventId:int}", async (
            int eventId,
            Handler handler,
            CancellationToken cancellationToken) =>
        {
            var result = await handler.HandleAsync(eventId, cancellationToken);

            return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblem();
        });
}

We have the Response record that acts as the result class. The Handler class contains everything needed to execute the functionality, i.e. getting the tickets left for a particular event.

It talks to TicketingDbContext directly, and its Select() fetches only three columns and maps the response, so we don’t need a mapping library.

The endpoint then returns a 200 (OK) or a 404 (Not Found) depending on the result. Its route is relative, because all event routes will share one /api/events prefix, which we set in Program.cs.

If you’ve used the REPR pattern, this layout will look familiar.

Commands

A command class is similar to a query class we saw earlier. It also contains the nested classes for the request, the result, and the handler.

The ReserveTickets class is the slice that changes data. Let’s create EventTicketing/Features/Reservations/ReserveTickets.cs:

using System.ComponentModel.DataAnnotations;
using EventTicketing.Data;
using EventTicketing.Domain;
using Microsoft.EntityFrameworkCore;

namespace EventTicketing.Features.Reservations;

public static class ReserveTickets
{
    public sealed record Request([property: Range(1, 20)] int Quantity);

    public sealed record Response(int EventId, int TicketsReserved, int TicketsLeft);

    public sealed class Handler(TicketingDbContext dbContext)
    {
        public async Task<Result<Response>> HandleAsync(
            int eventId, int quantity, CancellationToken cancellationToken = default)
        {
            var ev = await dbContext.Events.FirstOrDefaultAsync(e => e.Id == eventId, cancellationToken);
            if (ev is null)
                return EventErrors.NotFound(eventId);

            var reservation = ev.Reserve(quantity);
            if (!reservation.IsSuccess)
                return reservation.Error!;

            await dbContext.SaveChangesAsync(cancellationToken);

            return new Response(ev.Id, quantity, ev.TicketsLeft);
        }
    }

    public static void MapEndpoint(IEndpointRouteBuilder app) =>
        app.MapPost("/{eventId:int}/reservations", async (
            int eventId,
            Request request,
            Handler handler,
            CancellationToken cancellationToken) =>
        {
            var result = await handler.HandleAsync(eventId, request.Quantity, cancellationToken);

            return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblem();
        });
}

Here, we have the Request record, the JSON body the client sends. Its [Range] attribute allows 1 to 20 tickets per reservation.

The Handler loads the event and asks it to reserve the tickets. When the event says no, the handler returns that error and saves nothing.

We won’t find a capacity check in the handler. The rule belongs to Event.Reserve(), and the handler only calls things in the right order.

The endpoint returns a 200 (OK), a 404 (Not Found) or a 409 (Conflict) depending on the result.

Each slice has its own Response, even though both carry an event ID, so changing one response can’t break the other endpoint.

Do We Need MediatR for Vertical Slices?

No. Each endpoint gets its handler from dependency injection and calls HandleAsync(), and the compiler checks every call.

Many vertical slice samples use MediatR, a library Bogard also wrote. Its pipeline of behaviors, such as logging or validation, wraps every request.

We can get that without it, since an endpoint filter on the /api/events route group runs around every endpoint in the group.

Then there’s the license to think about. According to the vendor’s licensing FAQ, MediatR 13.0.0 and later are dual-licensed.

You can use them under the Reciprocal Public License 1.5 at no cost if you accept its reciprocal (copyleft) obligations, or buy a commercial license.

A free Community license covers organizations that meet all four of the vendor’s conditions. Version 12.5.0, released in April 2025, is the last one under the Apache 2.0 license.

If its pipeline appeals to you and its license suits your team, MediatR remains a fair pick. Our CQRS with MediatR article builds that route step by step.

Configurations

So, at this point, we have the data, domain, and features classes set up. All we need to do to complete the application is to configure the database and register the slices.

Let’s start with the connection string. Replace EventTicketing/appsettings.json with:

{
  "ConnectionStrings": {
    "Ticketing": "Data Source=tickets.db"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*"
}

Then, let’s connect the slices to the framework. Replace EventTicketing/Program.cs with:

using EventTicketing.Data;
using EventTicketing.Features.Events;
using EventTicketing.Features.Reservations;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<TicketingDbContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("Ticketing")));

builder.Services.AddScoped<GetEventAvailability.Handler>();
builder.Services.AddScoped<ReserveTickets.Handler>();

builder.Services.AddProblemDetails();
builder.Services.AddValidation();

var app = builder.Build();

app.UseExceptionHandler();

using (var scope = app.Services.CreateScope())
{
    var dbContext = scope.ServiceProvider.GetRequiredService<TicketingDbContext>();
    dbContext.Database.EnsureCreated();
}

var events = app.MapGroup("/api/events");

GetEventAvailability.MapEndpoint(events);
ReserveTickets.MapEndpoint(events);

app.Run();

Here, we register the DbContext and each slice’s handler, and MapGroup() gives every event route the /api/events prefix. AddValidation() turns on the built-in validation that .NET 10 added for minimal APIs, which reads our [Range] attribute.

If a slice throws, UseExceptionHandler() and AddProblemDetails() answer with a 500 in the same JSON shape as our 404s and 409s. Our global exception handling article follows that path further.

EnsureCreated() builds and seeds the SQLite file on the first start. That’s fine for a demo, but a production app would use EF Core migrations.

Each new slice adds up to two lines here. With a few dozen slices, we can register minimal API endpoints automatically instead.

Running the Application

Once everything is done, we can run the application from the solution folder, on port 5000:

dotnet run --project EventTicketing -- --urls http://localhost:5000

We wait for Now listening on: http://localhost:5000 in the console. The first start also creates EventTicketing/tickets.db, which keeps its data between runs until we delete it.

To test our API endpoints, we keep the requests in an .http file, which Visual Studio, Rider and VS Code (with the REST Client extension) can send. Let’s create EventTicketing/EventTicketing.http:

GET http://localhost:5000/api/events/1

###

POST http://localhost:5000/api/events/1/reservations
Content-Type: application/json

{ "quantity": 3 }

###

POST http://localhost:5000/api/events/2/reservations
Content-Type: application/json

{ "quantity": 3 }

###

POST http://localhost:5000/api/events/1/reservations
Content-Type: application/json

{ "quantity": 0 }

###

GET http://localhost:5000/api/events/99

The first request checks event 1, which still has all 100 tickets:

{"eventId":1,"name":"Vertical Slice Live","ticketsLeft":100}

Three tickets later, the response carries the updated number:

{"eventId":1,"ticketsReserved":3,"ticketsLeft":97}

What happens if we want three seats at the jazz night, which only has two? We get a 409 Conflict, written as problem details:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
  "title": "Event.SoldOut",
  "status": 409,
  "detail": "Only 2 tickets left, 3 requested.",
  "traceId": "00-e2c7ef35fec170e07177adb99883b32c-4124bb7115cfc301-00"
}

The failure came back from Event.Reserve() as a plain return value. EF Core’s SQL log in the console shows the handler read the row and stopped, while the successful reservation wrote back only TicketsSold.

A request for zero tickets doesn’t reach the handler. The built-in validation checks it against [Range] and sends back a 400 by itself:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Quantity": [
      "The field Quantity must be between 1 and 20."
    ]
  },
  "traceId": "00-ceac20d686bce9963dadb04f9443ec34-831329966a3d0160-00"
}

Asking for an event that doesn’t exist gives a 404, titled Event.NotFound:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
  "title": "Event.NotFound",
  "status": 404,
  "detail": "Event 99 was not found.",
  "traceId": "00-9f76e5efc5037d8a3accb96e4c4af0a5-8a9eecbd16944067-00"
}

Adding a New Feature

What does it cost to add a feature? Let’s find out with a list of the events that still have tickets. Create EventTicketing/Features/Events/ListAvailableEvents.cs:

using EventTicketing.Data;
using Microsoft.EntityFrameworkCore;

namespace EventTicketing.Features.Events;

public static class ListAvailableEvents
{
    public sealed record AvailableEvent(int EventId, string Name, int TicketsLeft);

    public static void MapEndpoint(IEndpointRouteBuilder app) =>
        app.MapGet("/", async (TicketingDbContext dbContext, CancellationToken cancellationToken) =>
            await dbContext.Events
                .AsNoTracking()
                .Where(e => e.TicketsSold < e.Capacity)
                .Select(e => new AvailableEvent(e.Id, e.Name, e.Capacity - e.TicketsSold))
                .ToListAsync(cancellationToken));
}

This slice has no handler at all. There’s no rule to enforce and nothing can fail, so the endpoint queries the DbContext itself.

It also repeats the fields of GetEventAvailability.Response. We keep that duplication on purpose, so the two slices can change without affecting each other.

Then we add one line to Program.cs, right below the line that maps ReserveTickets:

ListAvailableEvents.MapEndpoint(events);

Let’s restart the app, then add these two requests to the end of EventTicketing.http and send them:

###

POST http://localhost:5000/api/events/2/reservations
Content-Type: application/json

{ "quantity": 2 }

###

GET http://localhost:5000/api/events

The reservation takes the last two jazz tickets, and the list now holds only the first event:

[{"eventId":1,"name":"Vertical Slice Live","ticketsLeft":97}]

So, the new feature cost us one new file and one new line in Program.cs. We didn’t open a single existing slice.

We added the same feature to a layered version of this app, split into Domain, Application, Infrastructure and Api projects plus two test projects. It changed six files in four projects.

It also broke the build, because the unit test project’s fake repository no longer implemented the interface:

ReserveTicketsHandlerTests.cs(37,56): error CS0535: 'FakeEventRepository' does not implement interface member 'IEventRepository.ListAvailableAsync(CancellationToken)'

The layers get something back for those files: their handler never touches EF Core, so its tests can use a fake. Part of the gap is our shortcut too, since this slice has no handler.

Testing a Slice Against a Real Database

Our handlers talk to EF Core directly, so how do we test them? We give them a real database: SQLite, running in memory.

Let’s stop the app first. The tests get a project of their own:

dotnet new classlib -n EventTicketing.Tests
dotnet solution add EventTicketing.Tests

Remove the template’s Class1.cs, and replace EventTicketing.Tests/EventTicketing.Tests.csproj with:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <IsTestProject>true</IsTestProject>
  </PropertyGroup>

  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
    <PackageReference Include="xunit.v3.mtp-off" Version="4.0.1" />
    <PackageReference Include="xunit.runner.visualstudio" Version="4.0.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\EventTicketing\EventTicketing.csproj" />
  </ItemGroup>

</Project>

The project references the app, which brings EF Core and SQLite along. xunit.v3.mtp-off keeps xUnit v3 on the classic runner, so a plain dotnet test works.

Now, let’s create EventTicketing.Tests/ReserveTicketsTests.cs:

using EventTicketing.Data;
using EventTicketing.Features.Reservations;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

namespace EventTicketing.Tests;

public sealed class ReserveTicketsTests : IDisposable
{
    private readonly SqliteConnection _connection = new("Data Source=:memory:");

    public ReserveTicketsTests() => _connection.Open();

    public void Dispose() => _connection.Dispose();

    private TicketingDbContext CreateDbContext()
    {
        var options = new DbContextOptionsBuilder<TicketingDbContext>()
            .UseSqlite(_connection)
            .Options;

        var dbContext = new TicketingDbContext(options);
        dbContext.Database.EnsureCreated();

        return dbContext;
    }

    [Fact]
    public async Task HandleAsync_WhenTicketsAreAvailable_SavesTheReservation()
    {
        var handler = new ReserveTickets.Handler(CreateDbContext());

        var result = await handler.HandleAsync(eventId: 1, quantity: 3, TestContext.Current.CancellationToken);

        Assert.True(result.IsSuccess);
        Assert.Equal(97, result.Value.TicketsLeft);

        var saved = await CreateDbContext().Events.SingleAsync(e => e.Id == 1, TestContext.Current.CancellationToken);
        Assert.Equal(3, saved.TicketsSold);
    }

    [Fact]
    public async Task HandleAsync_WhenTheEventIsSoldOut_SavesNothing()
    {
        var handler = new ReserveTickets.Handler(CreateDbContext());

        var result = await handler.HandleAsync(eventId: 2, quantity: 3, TestContext.Current.CancellationToken);

        Assert.False(result.IsSuccess);
        Assert.Equal("Event.SoldOut", result.Error!.Code);

        var saved = await CreateDbContext().Events.SingleAsync(e => e.Id == 2, TestContext.Current.CancellationToken);
        Assert.Equal(0, saved.TicketsSold);
    }
}

Data Source=:memory: gives each connection its own database, and xUnit creates a new instance of the class for every test, so every test starts from the two seeded events.

Each test then reads the event back through a fresh DbContext, which has to ask the database.

Back in the solution folder, let’s run the tests:

dotnet test

The last two lines tell us how it went:

Test summary: total: 2, failed: 0, succeeded: 2, skipped: 0, duration: 4.9s
Build succeeded in 7.0s

How long do the tests themselves take? On our machine, the first test in a run took about a second, whichever test it was, and the next one took between 60 and 170 milliseconds across two sessions.

The layered version’s four unit tests, two of them on a handler with a fake repository, finish in 35 to 55 milliseconds. In return, our tests run the actual query, mapping and save, none of which a fake can check.

If your app runs on SQL Server or PostgreSQL, Microsoft’s guide to choosing an EF Core testing strategy recommends testing against that real database.

It suggests a repository layer only when we truly need a test double.

For routing, validation and JSON, we can add integration tests with WebApplicationFactory.

Keeping the Slices Apart With an Architecture Test

What stops one slice from reaching into another? So far, nothing. All our slices live in one project, so the compiler lets any class use any other.

Here’s the rule we want to hold:

Two feature folders side by side, Features/Events with GetEventAvailability and ListAvailableEvents, and Features/Reservations with ReserveTickets. A solid arrow runs from each folder down to a shared code box holding Domain (Event, EventErrors, Result) and Data (TicketingDbContext). A dashed red arrow from Reservations to Events is crossed out and labeled: not allowed, SliceTests fails.

Each feature folder may use the shared code, and no feature may use another.

NetArchTest.Rules can check it by inspecting the built DLL. Let’s add it to the test project:

dotnet package add NetArchTest.Rules --version 1.3.2 --project EventTicketing.Tests

Then, let’s create EventTicketing.Tests/SliceTests.cs:

using EventTicketing.Features.Reservations;
using NetArchTest.Rules;

namespace EventTicketing.Tests;

public class SliceTests
{
    [Fact]
    public void Features_DoNotDependOnEachOther()
    {
        var assembly = typeof(ReserveTickets).Assembly;

        var features = assembly.GetTypes()
            .Select(t => t.Namespace)
            .OfType<string>()
            .Where(ns => ns.StartsWith("EventTicketing.Features."))
            .Distinct()
            .ToArray();

        foreach (var feature in features)
        {
            var otherFeatures = features.Where(f => f != feature).ToArray();

            var result = Types.InAssembly(assembly)
                .That()
                .ResideInNamespace(feature)
                .ShouldNot()
                .HaveDependencyOnAny(otherFeatures)
                .GetResult();

            Assert.True(result.IsSuccessful,
                $"{feature} depends on another feature: " + string.Join(", ", result.FailingTypeNames ?? []));
        }
    }
}

Our test finds every namespace under EventTicketing.Features, one per feature folder, and checks that no type in it depends on another feature. If we run dotnet test now, all three tests pass.

Now, let’s break the rule on purpose.

Say we need a receipt for a reservation, and we reuse the availability response because it already has the event’s name. This is exactly the coupling we’re guarding against, so don’t keep this file. Create EventTicketing/Features/Reservations/ReservationReceipt.cs:

using EventTicketing.Features.Events;

namespace EventTicketing.Features.Reservations;

public sealed record ReservationReceipt(GetEventAvailability.Response Event, int TicketsReserved);

The app still builds. Let’s run the tests again:

dotnet test

This time the architecture test fails and names the type:

  EventTicketing.Tests test net10.0 failed with 1 error(s) (2.5s)
    ...\EventTicketing.Tests\SliceTests.cs(31): error TESTERROR:
      EventTicketing.Tests.SliceTests.Features_DoNotDependOnEachOther (99ms): Error Message: EventTicketing.Features.Reservations depends on another feature: EventTicketing.Features.Reservations.ReservationReceipt
...

Test summary: total: 3, failed: 1, succeeded: 2, skipped: 0, duration: 2.5s
Build failed with 1 error(s) in 4.2s

Once we remove ReservationReceipt.cs, the three tests go back to green. With this test in CI, a feature that reaches into another one breaks the build before a reviewer has to catch it.

What Does a Vertical Slice Folder Structure Look Like?

A vertical slice folder structure groups files by feature. A common layout keeps three folders in one ASP.NET Core project.

The Data folder contains the DbContext and its seed data. The Domain folder contains the entities, their rules and small shared types such as Result. The Features folder contains the vertical slices.

Each subfolder inside the Features folder is one business area, such as Events or Reservations.

Inside it, every request gets its own file, named after what it does, such as ReserveTickets.cs. That file is a self-contained use case: its request and response types, its handler and its endpoint.

Program.cs stays at the root of the project and wires up every slice. When a slice outgrows one file, it becomes a folder with the same name.

What we won’t find is a Services or Repositories folder that every feature depends on. That folder would be a layer again.

Here’s our finished app, with every file we created except the project files:

EventTicketing.slnx
EventTicketing/
    Data/
        TicketingDbContext.cs
    Domain/
        Event.cs
        EventErrors.cs
        Result.cs
    Features/
        Events/
            GetEventAvailability.cs
            ListAvailableEvents.cs
        Reservations/
            ReserveTickets.cs
    EventTicketing.http
    Program.cs
    ResultExtensions.cs
    appsettings.json
EventTicketing.Tests/
    ReserveTicketsTests.cs
    SliceTests.cs

Each feature is one file, and none of the five shared files changed when we added the third one.

Should We Use Vertical Slices or Clean Architecture?

We built this same app the Clean Architecture way too, in four projects, with the same Event entity and the same two starting features. Neither style wins everywhere, so which one should we pick?

Both keep the business rule in Event.Reserve(), so a rich domain model works in either style. The difference is where everything else lives.

Adding one read-only feature took 2 files in 1 project with slices, and 6 files in 4 projects with layers.

Testing runs the other way: the layered unit tests use a fake and finish in well under 100 milliseconds, while our slice tests take about a second.

In Clean Architecture, project references keep code in its place, because the compiler refuses a reference that points the wrong way. In a sliced app, an architecture test has to do it.

We’d pick vertical slices for an app with many independent features and light rules, such as an admin backend. We’d pick Clean Architecture when many features share rules that must never break.

Plenty of apps mix the two, and once one app spans several business areas, we can split it into a modular monolith and let each module pick slices or layers.

Here’s the comparison in one place:

Vertical slicesClean Architecture
Code is grouped byFeature: one folder per business area, one file per requestLayer: Domain, Application, Infrastructure and Api projects
Projects in our sample1 app project and 1 test project4 app projects and 2 test projects
Adding "list available events" (measured)2 files in 1 project, 18 new lines6 files in 4 projects, 30 new lines, and the build broke until the test fake changed
Where the overselling rule livesEvent.Reserve(), shared by the slicesEvent.Reserve(), in the Domain project
How a use case is testedAgainst SQLite in memory: the first test about 1 s, then 60 to 170 ms eachWith a hand-written fake of the repository: the 4 unit tests run in 35 to 55 ms
What keeps code in its placeAn architecture testProject references, plus architecture tests
We'd pick it whenFeatures are many and independent, and the rules are lightMany features share rules that must never break

The numbers come from our two samples, and your app will differ. The pattern behind them carries over: slices make features cheap to add, and layers make use cases cheap to test.

We build the layered version in full in our Clean Architecture in .NET course: a complete event ticketing system, with a fair hearing for vertical slices, including the cases where they win.

When Should We Not Use Vertical Slice Architecture?

When the team isn’t used to refactoring. Bogard’s own post names this catch: the style assumes someone notices when a handler grows too big and moves its logic into the domain. Without that habit, slices fill up with copied rules.

It’s also a poor fit when most features share the same complex rules. Once most of the app is shared domain code, layers protect it better.

And watch for a shared service layer under the slices. If every handler calls one IEventService, or a Common folder keeps growing, we’ve rebuilt the layers inside the Features folder.

Conclusion

In this article, we’ve learned about vertical slice architecture. We’ve seen how we separate the code into several vertical “slices” of complete features.

In addition, we saw how each slice owns its request, its handler and its query. Only the entity, its rule, its errors, the DbContext and the Result helpers stay shared, to achieve minimal coupling between the features.

Finally, we’ve learned how it differs from traditional architecture.

Where the traditional or clean architecture tries to separate the business rules from the user interface, the vertical slice architecture separates the code by features, even allowing a degree of code duplication to avoid the wrong abstraction.

If you’re moving an existing layered app toward slices, start with the next feature you add. Write it as a slice, and leave the old layers alone until you have a reason to touch them.

Tested with .NET 10.0.10, EF Core 10.0.12, xUnit v3 4.0.1 and NetArchTest.Rules 1.3.2.