Skip to main content
SEMastery
Architectureintermediate

The Best Way to Structure .NET Projects: Clean Architecture + Vertical Slices

A friendly guide to structuring .NET projects by mixing Clean Architecture with Vertical Slices, with diagrams, code, tables, and simple, beginner-ready advice.

11 min readUpdated September 19, 2025

A tiffin dabbawala and a thali kitchen

Imagine the famous dabbawalas of Mumbai. Every day they carry thousands of lunch boxes across the city and almost never make a mistake. How? They follow two simple ideas at the same time.

First, there is a strict, protected rule at the center: the right box must reach the right person. Nothing is allowed to break that rule. The trains may change, the bicycles may change, the streets may change, but the promise in the middle never changes.

Second, each dabbawala owns one full route from start to finish. He picks up, sorts, carries, and delivers his own boxes. He does not wait in a long queue behind other workers. He handles his slice of the city on his own.

Good .NET project structure works the same way. Clean Architecture gives you the strict, protected rule in the center, so your important business logic never depends on the database or the web framework. Vertical Slice Architecture lets each feature own its full path from start to finish, like one dabbawala owning one route.

In this article, you will learn how to combine both so your code stays safe in the middle and easy to change at the edges.

Two ideas, quickly

Before we mix them, let us be clear about what each idea means on its own.

Clean Architecture organises code by technical concern and points all dependencies inward. The pure business rules sit in the center. Databases, web frameworks, and email services sit on the outside. The outside knows about the inside, but the inside never knows about the outside. This protects your rules from changes in tools.

Vertical Slice Architecture (VSA) organises code by feature. Instead of one big folder of controllers, another of services, and another of repositories, you put everything for one feature, such as "Cancel Order", into a single slice: its endpoint, its request, its handler, and its data access, all in one place.

Figure 1: Clean Architecture points all dependencies inward toward the pure core. The database and web are on the outside.

Notice the arrows. The Domain in the center has zero arrows leaving it. It depends on nothing. That is the heart of Clean Architecture.

Why combine them?

People often argue about which is better. The honest answer is that they solve different problems, so you do not have to choose.

Clean Architecture is great at protecting complex rules, but on its own it can spread one feature across many folders. To add a single "Cancel Order" feature, you might edit a controller here, a service there, an interface somewhere else, and a repository far away. New developers get lost.

Vertical Slices fix that by keeping a feature together. But pure VSA, with no center, can let business rules leak into every slice and get copied around.

So we combine them: keep the protected core from Clean Architecture, and slice the outer feature code by feature using VSA. The core stays pure and tested. The features stay together and easy to find.

ConcernClean Architecture aloneVertical Slices aloneCombined
Protect business rulesStrongWeakStrong
Find all code for a featureHardEasyEasy
Onboarding new developersSlowerFasterFaster
Risk of duplicated logicLowHigherLow to medium
Good for complex domainsYesSometimesYes

The project layout

A clean, friendly starting point uses four projects. Each one has a clear job.

The four-project solution

Web
Application
Domain
Infrastructure

Steps

1

Web

Hosts feature slices and endpoints

2

Application

Use cases and slice handlers

3

Domain

Pure entities and rules, no dependencies

4

Infrastructure

EF Core, email, files, external APIs

Dependencies flow left to right, but all point inward toward Domain.

Here is how the folders look on disk. The big change from classic Clean Architecture is the Features folder, which holds one slice per feature instead of separate Services and Controllers folders.

src/
  Domain/                 // pure core: entities, value objects, rules
    Orders/
      Order.cs
      OrderStatus.cs
  Application/            // use cases, grouped as vertical slices
    Features/
      Orders/
        CancelOrder/
          CancelOrderCommand.cs
          CancelOrderHandler.cs
          CancelOrderValidator.cs
        PlaceOrder/
          PlaceOrderCommand.cs
          PlaceOrderHandler.cs
    Abstractions/
      IOrderRepository.cs   // interface lives here, inside
  Infrastructure/        // EF Core, email, the outside world
    Persistence/
      AppDbContext.cs
      OrderRepository.cs    // implements the interface above
  Web/                   // ASP.NET Core host
    Endpoints/
      OrderEndpoints.cs
    Program.cs

The key rule never changes: Domain depends on nothing, and Application depends only on Domain. The Infrastructure and Web projects sit on the outside and depend inward.

A feature as one slice

Let us build the "Cancel Order" feature as a single slice. Everything you need lives close together, but the pure rule still lives in the Domain.

First, the Domain entity. It holds the real rule: you cannot cancel an order that is already shipped. This rule has no idea a database or web request exists.

namespace Domain.Orders;
 
public sealed class Order
{
    public Guid Id { get; private set; }
    public OrderStatus Status { get; private set; }
 
    public void Cancel()
    {
        if (Status == OrderStatus.Shipped)
        {
            throw new InvalidOperationException(
                "A shipped order cannot be cancelled.");
        }
 
        Status = OrderStatus.Cancelled;
    }
}

Next, the slice in the Application layer. It is a small command plus a handler. Notice it depends on an interface, IOrderRepository, not on EF Core. The interface lives inside the Application layer, so the dependency still points inward.

namespace Application.Features.Orders.CancelOrder;
 
public sealed record CancelOrderCommand(Guid OrderId);
 
public sealed class CancelOrderHandler
{
    private readonly IOrderRepository _orders;
 
    public CancelOrderHandler(IOrderRepository orders) => _orders = orders;
 
    public async Task HandleAsync(CancelOrderCommand command, CancellationToken ct)
    {
        var order = await _orders.GetByIdAsync(command.OrderId, ct)
            ?? throw new KeyNotFoundException("Order not found.");
 
        order.Cancel();              // the pure rule runs here
        await _orders.SaveAsync(order, ct);
    }
}

Finally, the endpoint in the Web layer. With minimal APIs you do not even need a controller. This keeps the slice thin.

app.MapPost("/orders/{id}/cancel", async (
    Guid id, CancelOrderHandler handler, CancellationToken ct) =>
{
    await handler.HandleAsync(new CancelOrderCommand(id), ct);
    return Results.NoContent();
});

Read that route carefully: POST /orders/{id}/cancel. All three pieces, the command, the handler, and the endpoint, belong to the same feature. A new teammate can open the CancelOrder folder and understand the whole thing in minutes.

How a request flows

Let us follow one request all the way through. A user clicks "Cancel" in the app. Here is the journey.

Figure 2: A cancel request flows inward to the pure rule, then back out to the response.

The important detail is the direction. The web edge calls inward. The pure Order.Cancel() rule sits at the deepest point and knows nothing about HTTP or EF Core. If you swap the database tomorrow, this rule does not change at all.

Where do the layers actually point?

A common worry is, "If the slice has an endpoint, a handler, and a repository, does it break the dependency rule?" No, and the next diagram shows why. The interface sits inside, and the database class on the outside implements it.

Figure 3: The interface lives inside Application. Infrastructure implements it from the outside, so dependencies still point inward.

This is called dependency inversion, and it is the glue that lets Clean Architecture and Vertical Slices live together. The slice feels self-contained, but the actual database code stays in Infrastructure, on the outside, where it belongs.

Wiring it up in Program.cs

The Web project connects everything at startup. This is the one place that knows about all the layers, because its whole job is to assemble them.

var builder = WebApplication.CreateBuilder(args);
 
// Infrastructure: the real database implementation
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
 
// Application: register slice handlers
builder.Services.AddScoped<CancelOrderHandler>();
builder.Services.AddScoped<PlaceOrderHandler>();
 
var app = builder.Build();
app.MapOrderEndpoints();   // extension method that maps each slice
app.Run();

Notice the line AddScoped<IOrderRepository, OrderRepository>(). The inside asked for an interface; the outside provides the real class. This single line is where dependency inversion becomes real at runtime.

A note on MediatR and tools

For years, the popular way to build slice handlers was a library called MediatR. In 2025 it moved to a commercial license, with only a free Community edition for smaller companies, non-profits, and learning. The same happened to MassTransit and AutoMapper.

This is not a problem. The patterns here do not need any of them. A "handler" is just a small class with a method, exactly like the CancelOrderHandler above. If you want a mediator-style dispatch, you can write a tiny one yourself, use minimal API endpoints directly, or pick an open-source library such as Paramore Brighter.

ToolLicense in 2026Do you need it?
Plain handler classesFree, built inThis is enough for most apps
MediatRCommercial, free Community tierOptional, not required
MassTransitCommercial (v8 last free)Only for advanced messaging
Minimal API endpointsFree, built inGreat for thin slices

The lesson: structure is about how you organise code, not about which paid library you install.

When to add the deep core

You do not always need all four projects on day one. Structure should grow with the problem.

Grow your structure

Start
Slices
Add core
Split projects

Steps

1

Start

One project, simple CRUD

2

Slices

Group code by feature in folders

3

Add core

Move pure rules into Domain

4

Split projects

Separate Application and Infrastructure

Add layers only when the pain appears, not before.

A simple guide: if your app is mostly reading and writing data with few rules, stay light. As real business rules appear, like "a shipped order cannot be cancelled" or "a refund needs manager approval", give those rules a protected home in the Domain. That is the moment Clean Architecture starts to earn its keep.

Common mistakes to avoid

A few traps catch many teams. Watch out for these.

  • Putting EF Core in the Domain. The moment your pure entity uses a database attribute, the rule is no longer pure. Keep persistence in Infrastructure.
  • Sharing one giant handler base class with lots of logic. Slices should be independent. Shared logic belongs in the Domain or in small, clear helpers, not in a heavy base class.
  • Slicing too early. For a tiny app, ten slice folders can feel like more ceremony than value. Start simple.
  • Letting one slice call another slice's handler. If two features need the same rule, that rule belongs in the Domain, not copied between slices.

Keep the center pure, keep the slices independent, and most other choices become easy.

References and further reading

Quick recap

  • Clean Architecture points all dependencies inward to a pure business core that has no database or framework code.
  • Vertical Slice Architecture groups all the code for one feature into a single slice, so features are easy to find and change.
  • You can combine them: keep the protected core, and slice the outer feature code by feature.
  • A sensible layout is four projects: Domain, Application, Infrastructure, and Web, with the Features folder holding one slice each.
  • Interfaces live inside the Application layer; the database class implements them from Infrastructure, so dependencies still point inward.
  • You do not need MediatR or any paid tool. A handler is just a small class. Use plain classes or minimal APIs.
  • Grow your structure with the problem. Start light, and add the deep core only when real business rules appear.

Related Posts