Vertical Slice Architecture in .NET - Alternative to Clean Architecture

Muhammad Rizwan
2026-02-20
24 min read
Vertical Slice Architecture in .NET - Alternative to Clean Architecture

Let me ask you something. How many times have you opened a .NET solution with a Clean Architecture layout, clicked through four different projects just to understand how a single endpoint works, and thought: "There has to be a simpler way"?

If that sounds familiar, you are not alone. And you are not wrong for thinking it.

Clean Architecture is powerful. I wrote an entire article about it and I stand by everything in it. But it is not the only way to organize a .NET application, and for a growing number of teams and projects, Vertical Slice Architecture is proving to be a more productive, more maintainable, and frankly more enjoyable way to build software.

This is not a "Clean Architecture is dead" article. It is a practical look at an alternative that might fit your next project better than the traditional layered approach.

Let us dig in.


The Problem With Layers

Before we talk about vertical slices, we need to understand what they are reacting against.

In a traditional layered architecture, whether it is classic N-tier or Clean Architecture, your code is organized horizontally. You have a Controllers folder, a Services folder, a Repositories folder, a Models folder. Every feature you build is spread across all of these layers.

Want to add a "Create Product" feature? You touch the controller layer, the service layer, the repository layer, the DTO layer, the validation layer, the mapping layer, and probably a few interfaces in between. Want to modify how products are created? Same thing. You are bouncing between folders and projects like a pinball.

This creates a few real problems:

  • Scattered features: The code for a single use case lives in five or six different places. Understanding what "Create Product" does requires reading files spread across the entire solution.
  • Coupling through abstractions: Your IProductService interface has fifteen methods on it because every product-related operation was funneled through the same service class. Change one, risk breaking others.
  • Premature generalization: You end up creating base classes, generic repositories, and shared abstractions before you know if multiple features actually need them.
  • High ceremony for simple changes: Adding a straightforward endpoint requires creating a controller method, a service method, a repository method, DTOs, mappings, and interface updates. That is a lot of ceremony for something that might just be a database query.

Layered Architecture vs Vertical Slice Architecture

None of this means layered architecture is bad. It means it introduces coupling between features that share the same horizontal layers, and that coupling has a cost, especially as your application grows.


What Is Vertical Slice Architecture?

Vertical Slice Architecture flips the organizational axis. Instead of grouping code by technical concern (controllers, services, repositories), you group code by feature (Create Product, Get Product, Update Price).

Each feature, each "slice" contains everything it needs to handle a single request from start to finish: the endpoint, the request model, the validation, the business logic, the data access, and the response. All in one place.

The term was popularized by Jimmy Bogard (the creator of MediatR and AutoMapper) when he started talking about how his teams at Headspring organized their .NET applications. The core idea is simple:

Minimize coupling between slices. Maximize cohesion within a slice.

A vertical slice is essentially a self-contained unit of behavior. It handles one request and does one thing. It does not share services, repositories, or base classes with other slices unless there is a genuinely good reason to.

Here is what a single slice looks like conceptually:

  1. HTTP Request comes in
  2. Endpoint receives the request and maps it to a request object
  3. Validator checks the input (optional but recommended)
  4. Handler executes the business logic and talks to the database
  5. Response goes back to the client

That entire flow lives in one file or one small folder. Nothing else in the application needs to know or care about it.

Vertical Slice Request Flow


Free Newsletter

Enjoying the article? Stay in the loop.

  • Production-ready code samples every week
  • In-depth .NET, C# & React tutorials
  • Career tips & dev insights
500+ developers · No spam · Unsubscribe anytime

Join the community

Get new articles delivered every week.

No credit card · No spam · Cancel anytime · Learn more

How Is This Different From Clean Architecture?

This is the question I get the most. If you have read my Clean Architecture article, you know I am a fan of that approach too. So let me be precise about where they differ.

Clean Architecture organizes code in concentric layers with strict dependency rules. The domain is at the center, use cases wrap around it, and infrastructure is on the outside. Dependencies always point inward. It is excellent for applications with complex domain logic that needs to be protected from infrastructure concerns.

Vertical Slice Architecture organizes code by feature. Each slice is independent and self-contained. There are no enforced layers. Each slice can use whatever pattern makes sense for that specific feature, one slice might use a full repository pattern, another might query the database directly with EF Core, and that is perfectly fine.

Aspect Clean Architecture Vertical Slice Architecture
Organization By technical layer By feature/use case
Coupling Features coupled through shared abstractions Features decoupled from each other
Dependency rules Strict inward-only dependencies No enforced dependency direction
Abstractions Many interfaces and abstractions upfront Minimal - add abstractions when needed
Code reuse Through shared services and repositories Through shared utilities only when proven
Consistency Every feature follows the same pattern Each feature uses the pattern that fits
Ideal for Complex domains, large teams, long-lived systems Feature-heavy apps, rapid iteration, microservices

The honest truth? They are not mutually exclusive. You can use vertical slices inside a Clean Architecture solution. You can have a domain layer with rich entities and still organize your use cases as self-contained slices. The architecture world is not as binary as Twitter debates make it seem.

But in practice, most teams pick one organizational style as their primary approach, and vertical slices are an increasingly popular choice.


Building Vertical Slices in .NET - The Practical Part

Enough philosophy. Let us write some actual code.

I am going to build a simple Product API using Vertical Slice Architecture. The goal is to show you how the slices are structured, how they handle different levels of complexity, and how they stay independent of each other.

Project Structure

Here is how I organize a vertical slice project:

Vertical Slice Folder Structure

src/
  ProductApi/
    Features/
      Products/
        CreateProduct/
          CreateProductEndpoint.cs
          CreateProductHandler.cs
          CreateProductValidator.cs
          CreateProductRequest.cs
          CreateProductResponse.cs
        GetProduct/
          GetProductEndpoint.cs
          GetProductHandler.cs
        GetAllProducts/
          GetAllProductsEndpoint.cs
          GetAllProductsHandler.cs
        UpdatePrice/
          UpdatePriceEndpoint.cs
          UpdatePriceHandler.cs
          UpdatePriceValidator.cs
    Shared/
      Database/
        AppDbContext.cs
      Entities/
        Product.cs
      Middleware/
        ExceptionHandlingMiddleware.cs
    Program.cs

Notice a few things:

  • Every feature has its own folder under Features/Products/.
  • Each folder contains only the files needed for that slice.
  • There is no Services/ folder. No Repositories/ folder. No Interfaces/ folder full of IProductService and IProductRepository.
  • The Shared/ folder holds things that are genuinely shared the database context, entities, and cross-cutting concerns like middleware.

This structure communicates intent. When a new developer joins the team and needs to understand how "Create Product" works, they open one folder. Everything is there.


The Shared Parts

Before we build the slices, let us set up the things that are truly shared.

The Entity:

csharp
// Shared/Entities/Product.cs namespace ProductApi.Shared.Entities; public class Product { public Guid Id { get; set; } public string Name { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; public decimal Price { get; set; } public string Category { get; set; } = string.Empty; public bool IsActive { get; set; } = true; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? UpdatedAt { get; set; } }

The DbContext:

csharp
// Shared/Database/AppDbContext.cs using Microsoft.EntityFrameworkCore; using ProductApi.Shared.Entities; namespace ProductApi.Shared.Database; public class AppDbContext : DbContext { public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public DbSet<Product> Products => Set<Product>(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Product>(entity => { entity.HasKey(e => e.Id); entity.Property(e => e.Name).HasMaxLength(200).IsRequired(); entity.Property(e => e.Price).HasPrecision(18, 2); entity.Property(e => e.Category).HasMaxLength(100); }); } }

That is our shared foundation. Minimal, focused, and only the things that genuinely need to be shared. Now let us build the slices.


Slice 1: Create Product

This is a straightforward write operation with validation. The entire feature lives in one folder.

The Request and Response:

csharp
// Features/Products/CreateProduct/CreateProductRequest.cs namespace ProductApi.Features.Products.CreateProduct; public record CreateProductRequest( string Name, string Description, decimal Price, string Category ); public record CreateProductResponse( Guid Id, string Name, decimal Price, DateTime CreatedAt );

Using records here because they are perfect for immutable request and response objects. Clean, concise, and they give you value equality for free.

The Validator:

csharp
// Features/Products/CreateProduct/CreateProductValidator.cs using FluentValidation; namespace ProductApi.Features.Products.CreateProduct; public class CreateProductValidator : AbstractValidator<CreateProductRequest> { public CreateProductValidator() { RuleFor(x => x.Name) .NotEmpty().WithMessage("Product name is required") .MaximumLength(200).WithMessage("Product name cannot exceed 200 characters"); RuleFor(x => x.Price) .GreaterThan(0).WithMessage("Price must be greater than zero"); RuleFor(x => x.Category) .NotEmpty().WithMessage("Category is required") .MaximumLength(100).WithMessage("Category cannot exceed 100 characters"); } }

The Handler:

csharp
// Features/Products/CreateProduct/CreateProductHandler.cs using ProductApi.Shared.Database; using ProductApi.Shared.Entities; namespace ProductApi.Features.Products.CreateProduct; public class CreateProductHandler { private readonly AppDbContext _db; public CreateProductHandler(AppDbContext db) { _db = db; } public async Task<CreateProductResponse> HandleAsync( CreateProductRequest request, CancellationToken cancellationToken = default) { var product = new Product { Id = Guid.NewGuid(), Name = request.Name, Description = request.Description, Price = request.Price, Category = request.Category, CreatedAt = DateTime.UtcNow }; _db.Products.Add(product); await _db.SaveChangesAsync(cancellationToken); return new CreateProductResponse( product.Id, product.Name, product.Price, product.CreatedAt ); } }

Notice something? The handler uses AppDbContext directly. There is no IProductRepository wrapping EF Core just to satisfy an interface. The handler knows exactly what it needs, and it accesses it directly.

This is one of the key mindset shifts in Vertical Slice Architecture: do not abstract what does not need abstracting. If this slice only ever talks to EF Core, let it talk to EF Core.

The Endpoint:

csharp
// Features/Products/CreateProduct/CreateProductEndpoint.cs using FluentValidation; namespace ProductApi.Features.Products.CreateProduct; public static class CreateProductEndpoint { public static void Map(IEndpointRouteBuilder app) { app.MapPost("/api/products", async ( CreateProductRequest request, CreateProductHandler handler, IValidator<CreateProductRequest> validator, CancellationToken cancellationToken) => { var validationResult = await validator.ValidateAsync(request, cancellationToken); if (!validationResult.IsValid) { return Results.ValidationProblem( validationResult.ToDictionary()); } var result = await handler.HandleAsync(request, cancellationToken); return Results.Created($"/api/products/{result.Id}", result); }); } }

I am using Minimal APIs here because they align perfectly with the vertical slice philosophy. Each endpoint is mapped explicitly. No controller inheritance, no action filters you have to reason about, no [ApiController] magic. Just a function that handles a request.


Slice 2: Get Product By Id

This is a simple read operation. Notice how it is even leaner than the create slice because it does not need validation.

csharp
// Features/Products/GetProduct/GetProductHandler.cs using Microsoft.EntityFrameworkCore; using ProductApi.Shared.Database; namespace ProductApi.Features.Products.GetProduct; public record GetProductResponse( Guid Id, string Name, string Description, decimal Price, string Category, bool IsActive, DateTime CreatedAt ); public class GetProductHandler { private readonly AppDbContext _db; public GetProductHandler(AppDbContext db) { _db = db; } public async Task<GetProductResponse?> HandleAsync( Guid id, CancellationToken cancellationToken = default) { var product = await _db.Products .AsNoTracking() .FirstOrDefaultAsync(p => p.Id == id, cancellationToken); if (product is null) return null; return new GetProductResponse( product.Id, product.Name, product.Description, product.Price, product.Category, product.IsActive, product.CreatedAt ); } }
csharp
// Features/Products/GetProduct/GetProductEndpoint.cs namespace ProductApi.Features.Products.GetProduct; public static class GetProductEndpoint { public static void Map(IEndpointRouteBuilder app) { app.MapGet("/api/products/{id:guid}", async ( Guid id, GetProductHandler handler, CancellationToken cancellationToken) => { var result = await handler.HandleAsync(id, cancellationToken); return result is not null ? Results.Ok(result) : Results.NotFound(); }); } }

Two files. That is the entire feature. No interface, no service, no mapper configuration, no repository. Just a handler that reads from the database and an endpoint that calls it.

Compare this to what the same feature looks like in a traditional layered setup: you would have ProductController.GetById() calling IProductService.GetByIdAsync() calling IProductRepository.GetByIdAsync() calling _context.Products.FindAsync(), with DTOs and AutoMapper profiles in separate folders. Four layers deep for a query that is literally one line of EF Core.


Slice 3: Get All Products (With Filtering)

Let us make this slice slightly more interesting by adding query parameters for filtering and pagination.

csharp
// Features/Products/GetAllProducts/GetAllProductsHandler.cs using Microsoft.EntityFrameworkCore; using ProductApi.Shared.Database; namespace ProductApi.Features.Products.GetAllProducts; public record GetAllProductsRequest( string? Category = null, decimal? MinPrice = null, decimal? MaxPrice = null, int Page = 1, int PageSize = 20 ); public record ProductSummary( Guid Id, string Name, decimal Price, string Category, bool IsActive ); public record GetAllProductsResponse( List<ProductSummary> Products, int TotalCount, int Page, int PageSize ); public class GetAllProductsHandler { private readonly AppDbContext _db; public GetAllProductsHandler(AppDbContext db) { _db = db; } public async Task<GetAllProductsResponse> HandleAsync( GetAllProductsRequest request, CancellationToken cancellationToken = default) { var query = _db.Products.AsNoTracking().AsQueryable(); if (!string.IsNullOrWhiteSpace(request.Category)) query = query.Where(p => p.Category == request.Category); if (request.MinPrice.HasValue) query = query.Where(p => p.Price >= request.MinPrice.Value); if (request.MaxPrice.HasValue) query = query.Where(p => p.Price <= request.MaxPrice.Value); var totalCount = await query.CountAsync(cancellationToken); var products = await query .OrderBy(p => p.Name) .Skip((request.Page - 1) * request.PageSize) .Take(request.PageSize) .Select(p => new ProductSummary( p.Id, p.Name, p.Price, p.Category, p.IsActive)) .ToListAsync(cancellationToken); return new GetAllProductsResponse( products, totalCount, request.Page, request.PageSize); } }
csharp
// Features/Products/GetAllProducts/GetAllProductsEndpoint.cs namespace ProductApi.Features.Products.GetAllProducts; public static class GetAllProductsEndpoint { public static void Map(IEndpointRouteBuilder app) { app.MapGet("/api/products", async ( string? category, decimal? minPrice, decimal? maxPrice, int? page, int? pageSize, GetAllProductsHandler handler, CancellationToken cancellationToken) => { var request = new GetAllProductsRequest( category, minPrice, maxPrice, page ?? 1, pageSize ?? 20); var result = await handler.HandleAsync(request, cancellationToken); return Results.Ok(result); }); } }

This slice has filtering, pagination, and a projection and it is still just two files. Each slice grows to exactly the complexity it needs and no more.


Free Newsletter

Enjoying the article? Stay in the loop.

  • Production-ready code samples every week
  • In-depth .NET, C# & React tutorials
  • Career tips & dev insights
500+ developers · No spam · Unsubscribe anytime

Join the community

Get new articles delivered every week.

No credit card · No spam · Cancel anytime · Learn more

Slice 4: Update Product Price

This one is interesting because the business rule is non-trivial: you cannot set the price below zero, and you might want to track when prices change.

csharp
// Features/Products/UpdatePrice/UpdatePriceRequest.cs namespace ProductApi.Features.Products.UpdatePrice; public record UpdatePriceRequest(decimal NewPrice);
csharp
// Features/Products/UpdatePrice/UpdatePriceValidator.cs using FluentValidation; namespace ProductApi.Features.Products.UpdatePrice; public class UpdatePriceValidator : AbstractValidator<UpdatePriceRequest> { public UpdatePriceValidator() { RuleFor(x => x.NewPrice) .GreaterThan(0) .WithMessage("Price must be greater than zero") .LessThanOrEqualTo(99999.99m) .WithMessage("Price cannot exceed 99,999.99"); } }
csharp
// Features/Products/UpdatePrice/UpdatePriceHandler.cs using Microsoft.EntityFrameworkCore; using ProductApi.Shared.Database; namespace ProductApi.Features.Products.UpdatePrice; public class UpdatePriceHandler { private readonly AppDbContext _db; private readonly ILogger<UpdatePriceHandler> _logger; public UpdatePriceHandler(AppDbContext db, ILogger<UpdatePriceHandler> logger) { _db = db; _logger = logger; } public async Task<bool> HandleAsync( Guid productId, UpdatePriceRequest request, CancellationToken cancellationToken = default) { var product = await _db.Products .FirstOrDefaultAsync(p => p.Id == productId, cancellationToken); if (product is null) return false; var oldPrice = product.Price; product.Price = request.NewPrice; product.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(cancellationToken); _logger.LogInformation( "Product {ProductId} price updated from {OldPrice} to {NewPrice}", productId, oldPrice, request.NewPrice); return true; } }
csharp
// Features/Products/UpdatePrice/UpdatePriceEndpoint.cs using FluentValidation; namespace ProductApi.Features.Products.UpdatePrice; public static class UpdatePriceEndpoint { public static void Map(IEndpointRouteBuilder app) { app.MapPatch("/api/products/{id:guid}/price", async ( Guid id, UpdatePriceRequest request, UpdatePriceHandler handler, IValidator<UpdatePriceRequest> validator, CancellationToken cancellationToken) => { var validationResult = await validator.ValidateAsync(request, cancellationToken); if (!validationResult.IsValid) { return Results.ValidationProblem(validationResult.ToDictionary()); } var success = await handler.HandleAsync(id, request, cancellationToken); return success ? Results.NoContent() : Results.NotFound(); }); } }

This slice has validation, logging, and business logic. The next slice over might have none of that. And that is the point, each slice is exactly as complex as it needs to be.


Wiring It All Up

Here is what Program.cs looks like:

csharp
using FluentValidation; using Microsoft.EntityFrameworkCore; using ProductApi.Features.Products.CreateProduct; using ProductApi.Features.Products.GetAllProducts; using ProductApi.Features.Products.GetProduct; using ProductApi.Features.Products.UpdatePrice; using ProductApi.Shared.Database; var builder = WebApplication.CreateBuilder(args); // Database builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("Default"))); // Register handlers builder.Services.AddScoped<CreateProductHandler>(); builder.Services.AddScoped<GetProductHandler>(); builder.Services.AddScoped<GetAllProductsHandler>(); builder.Services.AddScoped<UpdatePriceHandler>(); // Register validators from this assembly builder.Services.AddValidatorsFromAssemblyContaining<Program>(); // Swagger builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); // Map all feature endpoints CreateProductEndpoint.Map(app); GetProductEndpoint.Map(app); GetAllProductsEndpoint.Map(app); UpdatePriceEndpoint.Map(app); app.Run();

Clean. Explicit. Every endpoint registration is visible right here. You know exactly what this API does by reading Program.cs.


What About MediatR?

You might be wondering: "Wait, most vertical slice examples use MediatR. Why are you not using it?"

Great question. And the answer is: you do not need MediatR for vertical slices.

MediatR is a useful tool. I wrote a whole article about it. It gives you pipeline behaviors, decoupled dispatch, and a consistent request/response pattern. Those are real benefits.

But MediatR also introduces indirection. When you send a request through IMediator.Send(), you lose the ability to navigate directly from the endpoint to the handler with a simple "Go to Definition" in your IDE. You are relying on the container and the mediator pipeline to connect the dots.

In a vertical slice architecture where the handler is literally in the same folder as the endpoint, that indirection does not buy you much. The endpoint already knows exactly which handler it needs. Dependency injection handles the wiring.

When MediatR makes sense with vertical slices:

  • You have cross-cutting concerns (logging, validation, caching) that you want to apply consistently through pipeline behaviors
  • You have many slices and want a uniform dispatch pattern
  • Your team is already familiar with MediatR and it is your standard

When plain DI is enough:

  • Your slices are relatively simple
  • You prefer explicit, navigable code over convention-based dispatch
  • You want to minimize external dependencies
  • Your cross-cutting concerns are handled at the middleware level anyway

Both approaches work. I showed the plain DI approach here because I want you to see that vertical slices are an architectural pattern, not a MediatR pattern. The mediator is optional.


Adding MediatR (If You Want It)

For completeness, here is what the Create Product slice looks like with MediatR:

csharp
// Features/Products/CreateProduct/CreateProduct.cs using MediatR; using ProductApi.Shared.Database; using ProductApi.Shared.Entities; namespace ProductApi.Features.Products.CreateProduct; // Request public record CreateProductCommand( string Name, string Description, decimal Price, string Category ) : IRequest<CreateProductResult>; // Response public record CreateProductResult( Guid Id, string Name, decimal Price, DateTime CreatedAt ); // Handler public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, CreateProductResult> { private readonly AppDbContext _db; public CreateProductCommandHandler(AppDbContext db) => _db = db; public async Task<CreateProductResult> Handle( CreateProductCommand request, CancellationToken cancellationToken) { var product = new Product { Id = Guid.NewGuid(), Name = request.Name, Description = request.Description, Price = request.Price, Category = request.Category, CreatedAt = DateTime.UtcNow }; _db.Products.Add(product); await _db.SaveChangesAsync(cancellationToken); return new CreateProductResult( product.Id, product.Name, product.Price, product.CreatedAt); } }

And the endpoint becomes:

csharp
app.MapPost("/api/products", async ( CreateProductCommand command, IMediator mediator, CancellationToken ct) => { var result = await mediator.Send(command, ct); return Results.Created($"/api/products/{result.Id}", result); });

With MediatR, you can also collapse the request, response, and handler into a single file per slice. Some teams love this "one file per feature" approach. Others find it cluttered. Both are valid.


Testing Vertical Slices

One of the common criticisms of vertical slices is: "But how do you unit test without interfaces and repositories?"

Here is the thing, you still test. You just test differently.

Integration Tests Per Slice

In vertical slice architecture, the most valuable tests are integration tests that test the entire slice from endpoint to database. Each slice is a small, self-contained unit of behavior, and the best way to verify it is to exercise it end-to-end.

csharp
public class CreateProductTests : IClassFixture<WebApplicationFactory<Program>> { private readonly HttpClient _client; public CreateProductTests(WebApplicationFactory<Program> factory) { _client = factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { // Replace real database with in-memory for testing services.RemoveAll<DbContextOptions<AppDbContext>>(); services.AddDbContext<AppDbContext>(options => options.UseInMemoryDatabase("TestDb_" + Guid.NewGuid())); }); }).CreateClient(); } [Fact] public async Task CreateProduct_WithValidData_ReturnsCreated() { // Arrange var request = new { Name = "Test Widget", Description = "A test product", Price = 29.99m, Category = "Widgets" }; // Act var response = await _client.PostAsJsonAsync("/api/products", request); // Assert response.StatusCode.Should().Be(HttpStatusCode.Created); var product = await response.Content .ReadFromJsonAsync<CreateProductResponse>(); product.Should().NotBeNull(); product!.Name.Should().Be("Test Widget"); product.Price.Should().Be(29.99m); } [Fact] public async Task CreateProduct_WithInvalidData_ReturnsBadRequest() { var request = new { Name = "", Price = -5m, Category = "" }; var response = await _client.PostAsJsonAsync("/api/products", request); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); } }

These tests are fast, reliable, and they test what actually matters, the behavior of the slice, not the internal implementation details.

Unit Tests When They Make Sense

If a handler has complex business logic that is worth testing in isolation, you absolutely can unit test it:

csharp
[Fact] public async Task UpdatePrice_WithValidProduct_UpdatesAndReturnsTrue() { // Arrange var options = new DbContextOptionsBuilder<AppDbContext>() .UseInMemoryDatabase("TestDb_" + Guid.NewGuid()) .Options; using var context = new AppDbContext(options); var existingProduct = new Product { Id = Guid.NewGuid(), Name = "Widget", Price = 10.00m, Category = "Gadgets" }; context.Products.Add(existingProduct); await context.SaveChangesAsync(); var handler = new UpdatePriceHandler( context, NullLogger<UpdatePriceHandler>.Instance); // Act var result = await handler.HandleAsync( existingProduct.Id, new UpdatePriceRequest(25.00m)); // Assert result.Should().BeTrue(); var updated = await context.Products.FindAsync(existingProduct.Id); updated!.Price.Should().Be(25.00m); updated.UpdatedAt.Should().NotBeNull(); }

The key insight is: you test at the level that gives you the most confidence. For simple CRUD slices, integration tests are usually sufficient. For slices with complex logic, add unit tests for the handler. You are not locked into one testing strategy for the entire application.


When Should You Use Vertical Slice Architecture?

This is the section that matters most. Patterns are not universally good or bad, they are contextually appropriate or inappropriate.

Vertical Slices Work Well When:

  • Your application is feature-heavy: APIs with many endpoints that are relatively independent of each other. Each feature can be built, tested, and deployed without affecting others.
  • You value developer velocity: Adding a new feature means creating a new folder and writing self-contained code. No ceremony, no touching shared abstractions, no updating service interfaces.
  • Your team practices trunk-based development or frequent PRs: Each slice is a small, reviewable unit. Pull requests are focused and easy to understand because all the code for a feature is in one place.
  • You are building microservices: Microservices already encourage small, focused units of behavior. Vertical slices align naturally with this mindset.
  • Your features have different complexity levels: Some endpoints are simple queries, others have complex business rules. Vertical slices let each feature be as simple or complex as it needs to be without forcing a uniform abstraction layer.

Vertical Slices Are Not Ideal When:

  • You have a rich, shared domain model: If your entities have significant behavior and many features interact with the same aggregate roots, Clean Architecture's explicit domain layer provides better protection for those invariants.
  • You need strict architectural enforcement: Clean Architecture's project-level dependency rules (enforced by the compiler) prevent developers from accidentally taking shortcuts. Vertical slices rely more on convention and code review.
  • Your team is very large and needs clear boundaries: In a large team with many developers, the explicit layer boundaries of Clean Architecture can prevent architectural drift.
  • Cross-cutting concerns dominate your use cases: If nearly every feature needs caching, authorization, audit logging, and event publishing, a shared pipeline (like MediatR behaviors or middleware) might be more practical than handling these in each slice.

Common Mistakes (And How To Avoid Them)

I have seen teams adopt vertical slices and still end up with the same problems they had with layered architecture, just organized differently. Here are the pitfalls to watch out for.

Mistake 1: Creating Shared Abstractions Too Early

The whole point of vertical slices is that each slice is self-contained. The moment you create an IRepository<T> or a BaseHandler<TRequest, TResponse>, you are reintroducing horizontal coupling.

The fix: Only extract shared code when you have three or more slices doing the exact same thing. Two is a coincidence. Three is a pattern. Until then, let each slice handle things its own way.

Mistake 2: One Giant File Per Slice

Some developers take the "everything in one place" idea too literally and put the endpoint, request, response, handler, validator, and database configuration in a single 500-line file.

The fix: Keep individual files focused. A folder per slice with multiple small files is much more maintainable than a single massive file. The goal is co-location, not consolidation.

Free Newsletter

Enjoying the article? Stay in the loop.

  • Production-ready code samples every week
  • In-depth .NET, C# & React tutorials
  • Career tips & dev insights
500+ developers · No spam · Unsubscribe anytime

Join the community

Get new articles delivered every week.

No credit card · No spam · Cancel anytime · Learn more

Mistake 3: Ignoring Cross-Cutting Concerns

Just because each slice is independent does not mean you should duplicate error handling, logging, or response formatting in every single one.

The fix: Use ASP.NET middleware for truly cross-cutting concerns. Exception handling middleware, request logging middleware, and authentication middleware apply to all requests regardless of which slice handles them. That is their job.

Mistake 4: No Consistent Convention

Without the guardrails of a layered architecture, teams sometimes end up with wildly inconsistent slice structures. One slice has a handler class, another uses a static method, another puts everything inline in the endpoint.

The fix: Agree on conventions upfront. Define what a "standard slice" looks like for your team. You can still deviate when it makes sense, but having a happy path reduces cognitive load for everyone.

Mistake 5: Forgetting About Shared Domain Logic

Some business rules apply across multiple features. "A product's price can never be negative" is not a rule for the UpdatePrice slice alone, it is a domain invariant.

The fix: Keep entity-level invariants in the entity itself. Validation in the handler is for request-level rules. Domain rules belong on the domain objects.

csharp
public class Product { private decimal _price; public decimal Price { get => _price; set { if (value < 0) throw new DomainException("Price cannot be negative"); _price = value; } } }

Vertical Slices + Clean Architecture: Can They Coexist?

Yes. And this is actually my preferred approach for larger applications.

Think of it this way: Clean Architecture gives you the macro structure the project boundaries, the dependency rules, the protected domain layer. Vertical Slice Architecture gives you the micro structure , how you organize code within the application layer.

src/
  Domain/
    Entities/
    ValueObjects/
    Exceptions/
  Application/
    Features/
      Products/
        CreateProduct/
        GetProduct/
        UpdatePrice/
      Orders/
        PlaceOrder/
        CancelOrder/
  Infrastructure/
    Persistence/
    ExternalServices/
  Api/
    Program.cs
    Middleware/

The domain layer is still pure and dependency-free. The infrastructure layer still handles all the external integrations. But the application layer is organized as vertical slices instead of shared services and interfaces.

This gives you the best of both worlds: domain protection from Clean Architecture and feature cohesion from Vertical Slices. It is the architecture I reach for most often on medium-to-large .NET projects.


Further Reading and Resources

If this article got you interested in Vertical Slice Architecture, here are some excellent resources to go deeper:


Final Thoughts

Vertical Slice Architecture is not a silver bullet. But it solves real problems that I have experienced firsthand on .NET projects: scattered feature code, premature abstractions, bloated service classes, and the frustrating ceremony of touching six files to add a simple endpoint.

The core idea is beautifully simple: organize code by what it does, not by what technical layer it belongs to. Let each feature be self-contained. Let it be as simple or complex as it needs to be. Do not force a uniform abstraction layer across features that have nothing in common except that they exist in the same application.

If you have been working with Clean Architecture and finding that the layers add friction rather than value, especially in smaller services or feature-heavy APIs give vertical slices a try. Start with one feature. See how it feels. You might not go back.

And if you do decide that your project needs the full power of Clean Architecture's domain protection, remember: you can combine both. Use vertical slices within the application layer. Get the cohesion and the protection.

Architecture is about tradeoffs. The best architecture is the one your team can understand, maintain, and evolve. For a growing number of .NET developers, that architecture is organized in vertical slices.

Happy coding.

Share this post

About the Author

Muhammad Rizwan

Muhammad Rizwan

Software Engineer · .NET & Cloud Developer

A passionate software developer with expertise in .NET Core, C#, JavaScript, TypeScript, React and Azure. Loves building scalable web applications and sharing practical knowledge with the developer community.


Did you find this helpful?

I would love to hear your thoughts. Your feedback helps me create better content for the community.

Leave Feedback

Related Articles

Explore more posts on similar topics

CQRS and MediatR in .NET: When It Is Worth It and When It Is Not

CQRS and MediatR in .NET: When It Is Worth It and When It Is Not

A practical, honest guide to implementing CQRS with MediatR in .NET. Learn how the pattern works, when it genuinely helps, when it just adds noise, and how to implement it step by step with real world C# code.

2026-02-2118 min read
Clean Architecture in .NET - Practical Guide

Clean Architecture in .NET - Practical Guide

A hands-on walkthrough of Clean Architecture in .NET - why it matters, how to structure your projects, and real code examples you can use today. No fluff, no over-engineering, just practical patterns that actually work in production.

2026-02-2416 min read
Repository Pattern Implementation in .NET 10

Repository Pattern Implementation in .NET 10

A complete walkthrough of implementing the Repository pattern in .NET 10 with Entity Framework Core. This guide covers the generic repository, specific repositories, the Unit of Work pattern, dependency injection, testing, and real production decisions with working C# code.

2026-02-2725 min read

Patreon Exclusive

Go deeper - exclusive content every month

Members get complete source-code projects, advanced architecture deep-dives, and monthly 1:1 code reviews.

$5/mo
Supporter
  • Supporter badge on website & my eternal gratitude
  • Your name listed on the website as a supporter
  • Monthly community Q&A (comments priority)
  • Early access to every new blog post
Join for $5/mo
Most Popular
$15/mo
Developer Pro
  • All Supporter benefits plus:
  • Exclusive .NET & Azure deep-dive posts (not on blog)
  • Full source-code project downloads every month
  • Downloadable architecture blueprints & templates
  • Private community access
Join for $15/mo
Best Value
$29/mo
Architect
  • All Developer Pro benefits plus:
  • Monthly 30-min 1:1 code review session
  • Priority answers to your architecture questions
  • Exclusive system design blueprints
  • Your name/logo featured on the website
  • Monthly live Q&A sessions
  • Early access to new courses or products
Join for $29/mo
Teams
$49/mo
Enterprise Partner
  • All Architect benefits plus:
  • Your company logo on my website & blog
  • Dedicated technical consultation session
  • Featured blog post about your company
  • Priority feature requests & custom content
Join for $49/mo

Secure billing via Patreon · Cancel anytime · Card & PayPal accepted

View Patreon page →

Your Feedback Matters

Have thoughts on my content, tutorials, or resources? I read every piece of feedback and use it to improve. No account needed. It only takes a minute.

Free Newsletter

Enjoying the article? Stay in the loop.

  • Production-ready code samples every week
  • In-depth .NET, C# & React tutorials
  • Career tips & dev insights
500+ developers · No spam · Unsubscribe anytime

Join the community

Get new articles delivered every week.

No credit card · No spam · Cancel anytime · Learn more