Vertical Slice Architecture on .NET 10 — Organize Code by Feature
Posted on: 4/23/2026 6:14:23 AM
Table of contents
- The Problem with Traditional Layered Architecture
- What is Vertical Slice Architecture?
- Architecture Comparison: VSA vs Clean Architecture
- Real-World Folder Structure on .NET 10
- Implementation with MediatR + Minimal API
- Pipeline Behaviors — Cross-Cutting Concerns
- Registration in Program.cs
- When NOT to Use Vertical Slice
- VSA Combined with CQRS
- Testing in VSA
- Migrating from Clean Architecture to VSA
- Supporting Libraries for VSA on .NET 10
- FastEndpoints — MediatR Alternative
- Conclusion
The Problem with Traditional Layered Architecture
If you've worked on large .NET projects, you're familiar with N-Layer architecture: Controller → Service → Repository → Database. This model is intuitive, easy to understand, and practically the default for every ASP.NET Core project. But as your project grows to hundreds of endpoints, cracks start to appear.
With Clean Architecture or Onion Architecture, you organize code by horizontal layers — all Controllers in one folder, all Services in another, all Repositories elsewhere. Adding a "Create Order" feature means creating or modifying files across 3-4 different directories.
graph TB
subgraph "Traditional Layered Architecture"
A["Controllers/"] --> B["OrderController"]
A --> C["ProductController"]
A --> D["UserController"]
E["Services/"] --> F["OrderService"]
E --> G["ProductService"]
E --> H["UserService"]
I["Repositories/"] --> J["OrderRepository"]
I --> K["ProductRepository"]
I --> L["UserRepository"]
end
style A fill:#e94560,stroke:#fff,color:#fff
style E fill:#e94560,stroke:#fff,color:#fff
style I fill:#e94560,stroke:#fff,color:#fff
style B fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style C fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style D fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style F fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style G fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style H fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style J fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style K fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style L fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
Layered architecture — code scattered by layer, not by feature
The consequence? When 5 developers work on 5 different features, they all modify the same Services/ and Repositories/ folders, causing constant merge conflicts. This is horizontal coupling — unrelated features sharing the same code space.
What is Vertical Slice Architecture?
Vertical Slice Architecture (VSA) completely inverts how you organize code: instead of grouping by layer, you group by feature. Each feature is a "vertical slice" cutting through from request to database, self-containing all necessary logic.
Core Principle
Each use case (create order, list products, update profile...) is an independent slice with its own request model, handler, validation, and response model. No shared abstractions unless genuinely needed.
graph TB
subgraph "Vertical Slice Architecture"
subgraph "Feature: CreateOrder"
A1["Request"] --> A2["Validator"]
A2 --> A3["Handler"]
A3 --> A4["DB Access"]
end
subgraph "Feature: GetProducts"
B1["Request"] --> B2["Handler"]
B2 --> B3["DB Access"]
end
subgraph "Feature: UpdateProfile"
C1["Request"] --> C2["Validator"]
C2 --> C3["Handler"]
C3 --> C4["DB Access"]
end
end
style A1 fill:#e94560,stroke:#fff,color:#fff
style A2 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style A3 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style A4 fill:#2c3e50,stroke:#fff,color:#fff
style B1 fill:#4CAF50,stroke:#fff,color:#fff
style B2 fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
style B3 fill:#2c3e50,stroke:#fff,color:#fff
style C1 fill:#ff9800,stroke:#fff,color:#fff
style C2 fill:#f8f9fa,stroke:#ff9800,color:#2c3e50
style C3 fill:#f8f9fa,stroke:#ff9800,color:#2c3e50
style C4 fill:#2c3e50,stroke:#fff,color:#fff
Vertical Slice — each feature is a self-contained, independent unit
This concept was popularized by Jimmy Bogard (creator of MediatR, AutoMapper) since 2018, but it took until 2025-2026 with the maturation of Minimal APIs in .NET and supporting libraries for VSA to truly explode in the .NET community.
Architecture Comparison: VSA vs Clean Architecture
| Criteria | Clean Architecture | Vertical Slice |
|---|---|---|
| Code organization | By layer (Controllers, Services, Repos) | By feature (CreateOrder, GetProducts...) |
| Coupling | Horizontal coupling across features in same layer | Each slice independent, minimal coupling |
| Adding features | Create/modify 3-5 files across folders | Add 1 file/folder |
| Removing features | Hunt and delete across layers | Delete 1 file/folder |
| Testing | Unit test each layer, heavy mocking | Integration test per use case, minimal mocking |
| Merge conflicts | High — multiple devs modify same folders | Low — each dev works on separate features |
| Best for | Complex domains, large teams needing standards | API-first, CRUD+logic, teams valuing velocity |
Real-World Folder Structure on .NET 10
├── Features/
│ ├── Orders/
│ │ ├── CreateOrder.cs
│ │ ├── GetOrderById.cs
│ │ ├── ListOrders.cs
│ │ ├── CancelOrder.cs
│ │ └── OrderMappings.cs
│ ├── Products/
│ │ ├── CreateProduct.cs
│ │ ├── GetProductById.cs
│ │ ├── SearchProducts.cs
│ │ └── UpdateProduct.cs
│ └── Users/
│ ├── Register.cs
│ ├── Login.cs
│ ├── GetProfile.cs
│ └── UpdateProfile.cs
├── Shared/
│ ├── AppDbContext.cs
│ ├── PipelineBehaviors/
│ └── Extensions/
├── Program.cs
└── appsettings.json
Golden Rule
Each file in Features/ contains the complete flow: Request → Validator → Handler → Response → Endpoint for a single use case. The Shared/ folder only contains truly shared infrastructure (DbContext, middleware, pipeline behaviors).
Implementation with MediatR + Minimal API
The most popular combo for VSA on .NET 10 is MediatR (mediator pattern) + Minimal API (replacing Controllers). MediatR acts as the dispatcher — receiving requests, finding the matching handler, and returning responses.
Package Installation
dotnet add package MediatR
dotnet add package FluentValidation
dotnet add package FluentValidation.DependencyInjectionExtensions
dotnet add package Carter
Example: CreateOrder Feature
The entire "Create Order" use case lives in a single file:
// Features/Orders/CreateOrder.cs
using Carter;
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace MyApp.Features.Orders;
// 1. Request & Response
public record CreateOrderRequest(
int CustomerId,
List<OrderItemDto> Items,
string? Note
) : IRequest<CreateOrderResponse>;
public record OrderItemDto(int ProductId, int Quantity);
public record CreateOrderResponse(
int OrderId,
decimal TotalAmount,
DateTime CreatedAt
);
// 2. Validation
public class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderValidator()
{
RuleFor(x => x.CustomerId).GreaterThan(0);
RuleFor(x => x.Items).NotEmpty()
.WithMessage("Order must contain at least 1 item");
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(i => i.ProductId).GreaterThan(0);
item.RuleFor(i => i.Quantity).InclusiveBetween(1, 100);
});
}
}
// 3. Handler — all business logic
public class CreateOrderHandler(AppDbContext db)
: IRequestHandler<CreateOrderRequest, CreateOrderResponse>
{
public async Task<CreateOrderResponse> Handle(
CreateOrderRequest request,
CancellationToken ct)
{
var products = await db.Products
.Where(p => request.Items.Select(i => i.ProductId).Contains(p.Id))
.ToDictionaryAsync(p => p.Id, ct);
var order = new Order
{
CustomerId = request.CustomerId,
Note = request.Note,
Items = request.Items.Select(i => new OrderItem
{
ProductId = i.ProductId,
Quantity = i.Quantity,
UnitPrice = products[i.ProductId].Price
}).ToList()
};
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return new CreateOrderResponse(
order.Id,
order.Items.Sum(i => i.UnitPrice * i.Quantity),
order.CreatedAt
);
}
}
// 4. Endpoint — Minimal API via Carter
public class CreateOrderEndpoint : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
app.MapPost("/api/orders", async (
CreateOrderRequest request,
ISender sender) =>
{
var result = await sender.Send(request);
return Results.Created($"/api/orders/{result.OrderId}", result);
})
.WithName("CreateOrder")
.WithTags("Orders")
.Produces<CreateOrderResponse>(201)
.ProducesValidationProblem();
}
}
Structure Analysis
All 4 components (Request/Response models, Validation, Business logic, Endpoint routing) live in a single file. When you need to modify order creation logic, you open exactly CreateOrder.cs — no jumping between Controller, Service, and Repository.
Example: GetOrderById (Query)
// Features/Orders/GetOrderById.cs
namespace MyApp.Features.Orders;
public record GetOrderByIdRequest(int OrderId) : IRequest<OrderDetailResponse?>;
public record OrderDetailResponse(
int Id,
string CustomerName,
List<OrderItemResponse> Items,
decimal Total,
string Status,
DateTime CreatedAt
);
public record OrderItemResponse(
string ProductName,
int Quantity,
decimal UnitPrice,
decimal Subtotal
);
public class GetOrderByIdHandler(AppDbContext db)
: IRequestHandler<GetOrderByIdRequest, OrderDetailResponse?>
{
public async Task<OrderDetailResponse?> Handle(
GetOrderByIdRequest request,
CancellationToken ct)
{
return await db.Orders
.Where(o => o.Id == request.OrderId)
.Select(o => new OrderDetailResponse(
o.Id,
o.Customer.Name,
o.Items.Select(i => new OrderItemResponse(
i.Product.Name,
i.Quantity,
i.UnitPrice,
i.Quantity * i.UnitPrice
)).ToList(),
o.Items.Sum(i => i.Quantity * i.UnitPrice),
o.Status.ToString(),
o.CreatedAt
))
.FirstOrDefaultAsync(ct);
}
}
public class GetOrderByIdEndpoint : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
app.MapGet("/api/orders/{orderId:int}", async (
int orderId,
ISender sender) =>
{
var result = await sender.Send(new GetOrderByIdRequest(orderId));
return result is not null
? Results.Ok(result)
: Results.NotFound();
})
.WithName("GetOrderById")
.WithTags("Orders");
}
}
Pipeline Behaviors — Cross-Cutting Concerns
A common question: "If each slice is independent, how do you handle cross-cutting concerns like validation, logging, caching?" The answer is MediatR's Pipeline Behaviors — they act like middleware for each request.
graph LR
A["HTTP Request"] --> B["Logging Behavior"]
B --> C["Validation Behavior"]
C --> D["Caching Behavior"]
D --> E["Handler"]
E --> F["Response"]
style A fill:#2c3e50,stroke:#fff,color:#fff
style B fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style C fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style D fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style E fill:#e94560,stroke:#fff,color:#fff
style F fill:#4CAF50,stroke:#fff,color:#fff
Pipeline Behaviors — middleware pattern for MediatR requests
Automatic Validation Behavior
// Shared/PipelineBehaviors/ValidationBehavior.cs
public class ValidationBehavior<TRequest, TResponse>(
IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
if (!validators.Any()) return await next(ct);
var context = new ValidationContext<TRequest>(request);
var failures = (await Task.WhenAll(
validators.Select(v => v.ValidateAsync(context, ct))))
.SelectMany(r => r.Errors)
.Where(f => f is not null)
.ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
return await next(ct);
}
}
Logging Behavior
// Shared/PipelineBehaviors/LoggingBehavior.cs
public class LoggingBehavior<TRequest, TResponse>(
ILogger<LoggingBehavior<TRequest, TResponse>> logger)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
var requestName = typeof(TRequest).Name;
logger.LogInformation("Handling {RequestName}", requestName);
var sw = Stopwatch.StartNew();
var response = await next(ct);
sw.Stop();
logger.LogInformation(
"Handled {RequestName} in {ElapsedMs}ms",
requestName, sw.ElapsedMilliseconds);
return response;
}
}
Registration in Program.cs
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// EF Core
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
// MediatR + Pipeline Behaviors
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
cfg.AddBehavior(typeof(IPipelineBehavior<,>),
typeof(LoggingBehavior<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>),
typeof(ValidationBehavior<,>));
});
// FluentValidation
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
// Carter (auto-discover endpoints)
builder.Services.AddCarter();
var app = builder.Build();
app.MapCarter(); // Auto-registers all ICarterModule implementations
app.Run();
Carter Auto-Scan
MapCarter() automatically scans the assembly for all classes implementing ICarterModule and registers their endpoints. No manual app.MapGet/MapPost in Program.cs — each feature registers its own routes.
When NOT to Use Vertical Slice
VSA is not a silver bullet. There are cases where traditional layered or Clean Architecture remains the better fit:
| Scenario | Use VSA? | Reason |
|---|---|---|
| API with 50+ endpoints, 70% CRUD | Yes | Each endpoint is a simple slice, easy to maintain |
| Complex domain with interlinked business rules | Consider | Domain logic needs DDD, aggregate roots — can be combined |
| New team needing code style standardization | No | Clean Architecture provides clearer guidelines |
| Small single-purpose microservice | Ideal | Few slices per service, very clean |
| Monolith gradually migrating to microservices | Yes | Each feature group can be extracted into a separate service later |
Common Pitfall
Don't abstract too early. When starting out, two handlers may have similar logic — let them duplicate. Only when a pattern repeats 3+ times AND the logic is truly identical should you extract a shared service. Premature abstraction in VSA turns it back into layered architecture.
VSA Combined with CQRS
VSA combines naturally with CQRS (Command Query Responsibility Segregation). In practice, each slice is already a separate command or query:
graph TB
subgraph "Commands (Write)"
C1["CreateOrder"]
C2["CancelOrder"]
C3["UpdateProduct"]
end
subgraph "Queries (Read)"
Q1["GetOrderById"]
Q2["ListOrders"]
Q3["SearchProducts"]
end
C1 --> DB1["Write DB"]
C2 --> DB1
C3 --> DB1
Q1 --> DB2["Read Replica"]
Q2 --> DB2
Q3 --> DB2
style C1 fill:#e94560,stroke:#fff,color:#fff
style C2 fill:#e94560,stroke:#fff,color:#fff
style C3 fill:#e94560,stroke:#fff,color:#fff
style Q1 fill:#4CAF50,stroke:#fff,color:#fff
style Q2 fill:#4CAF50,stroke:#fff,color:#fff
style Q3 fill:#4CAF50,stroke:#fff,color:#fff
style DB1 fill:#2c3e50,stroke:#fff,color:#fff
style DB2 fill:#2c3e50,stroke:#fff,color:#fff
VSA + CQRS — commands and queries naturally separate into individual slices
With this approach, query handlers can use Dapper or raw SQL for optimal performance, while command handlers stick with EF Core and full change tracking. Each slice decides its own data access strategy.
// Features/Orders/ListOrders.cs — Query using Dapper
public class ListOrdersHandler(IDbConnection db)
: IRequestHandler<ListOrdersRequest, PagedResult<OrderSummary>>
{
public async Task<PagedResult<OrderSummary>> Handle(
ListOrdersRequest request,
CancellationToken ct)
{
const string sql = """
SELECT o.Id, c.Name AS CustomerName,
COUNT(oi.Id) AS ItemCount,
SUM(oi.Quantity * oi.UnitPrice) AS Total,
o.Status, o.CreatedAt
FROM Orders o
JOIN Customers c ON o.CustomerId = c.Id
LEFT JOIN OrderItems oi ON o.Id = oi.OrderId
WHERE (@Status IS NULL OR o.Status = @Status)
GROUP BY o.Id, c.Name, o.Status, o.CreatedAt
ORDER BY o.CreatedAt DESC
OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY
""";
var results = await db.QueryAsync<OrderSummary>(sql, new
{
request.Status,
Skip = (request.Page - 1) * request.PageSize,
Take = request.PageSize
});
return new PagedResult<OrderSummary>(results.ToList(), totalCount);
}
}
Testing in VSA
A major advantage of VSA is that testing becomes simpler and more meaningful. Instead of unit testing each layer separately with dozens of mocks, you write integration tests per slice:
// Tests/Features/Orders/CreateOrderTests.cs
public class CreateOrderTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task CreateOrder_WithValidItems_ReturnsCreated()
{
// Arrange
var client = factory.CreateClient();
var request = new CreateOrderRequest(
CustomerId: 1,
Items: [new(ProductId: 1, Quantity: 2)],
Note: "Morning delivery"
);
// Act
var response = await client.PostAsJsonAsync("/api/orders", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content
.ReadFromJsonAsync<CreateOrderResponse>();
result!.OrderId.Should().BeGreaterThan(0);
result.TotalAmount.Should().BeGreaterThan(0);
}
[Fact]
public async Task CreateOrder_WithEmptyItems_ReturnsBadRequest()
{
var client = factory.CreateClient();
var request = new CreateOrderRequest(
CustomerId: 1, Items: [], Note: null);
var response = await client.PostAsJsonAsync("/api/orders", request);
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
}
Test Behavior, Not Implementation
Integration tests verify "valid request in → correct response out" rather than "Service calls the right Repository method." When you refactor internal code, tests still pass as long as behavior doesn't change. These are tests with real value.
Migrating from Clean Architecture to VSA
If your project currently uses Clean Architecture and you want to transition to VSA, here's a practical roadmap:
Supporting Libraries for VSA on .NET 10
| Library | Role | Notes |
|---|---|---|
| MediatR | Mediator / CQRS dispatcher | De facto standard, powerful pipeline behaviors |
| Carter | Auto-discovery for Minimal API | Replaces Controllers, auto-scans endpoint modules |
| FluentValidation | Request validation | Integrates well with MediatR via pipeline behavior |
| Wolverine | MediatR replacement + message bus | All-in-one: mediator + message queue + saga |
| FastEndpoints | VSA-style endpoint framework | No MediatR needed, provides request/handler/validator built-in |
| Immediate.Handlers | Source-generated mediator | Zero reflection, AOT-friendly, faster than MediatR |
FastEndpoints — MediatR Alternative
If you prefer not to use MediatR, FastEndpoints is a framework designed specifically for the VSA pattern on .NET. It provides request binding, validation, and endpoint routing in a single package:
// Features/Orders/CreateOrder.cs — with FastEndpoints
public class CreateOrderEndpoint
: Endpoint<CreateOrderRequest, CreateOrderResponse>
{
public override void Configure()
{
Post("/api/orders");
AllowAnonymous();
}
public override async Task HandleAsync(
CreateOrderRequest req,
CancellationToken ct)
{
var order = new Order
{
CustomerId = req.CustomerId,
Items = req.Items.Select(i => new OrderItem
{
ProductId = i.ProductId,
Quantity = i.Quantity
}).ToList()
};
await DbContext.Orders.AddAsync(order, ct);
await DbContext.SaveChangesAsync(ct);
await SendCreatedAtAsync<GetOrderByIdEndpoint>(
new { order.Id },
new CreateOrderResponse(order.Id, order.Total, order.CreatedAt),
cancellation: ct);
}
}
Conclusion
Vertical Slice Architecture isn't a "replacement" for Clean Architecture — it's a different approach for a different class of problems. When your project is primarily API endpoints with moderately complex business logic, VSA helps you:
- Reduce coupling — each feature is independent, changing one doesn't affect others
- Accelerate development — adding a new feature means creating 1 file
- Minimize merge conflicts — developers work on separate features, never touching each other's code
- Write meaningful tests — integration tests per use case instead of unit tests per layer
- Ease onboarding — new developers read 1 file to fully understand a feature
With the maturation of Minimal APIs on .NET 10, combined with MediatR (or FastEndpoints, Wolverine), implementing VSA has never been easier. Start by applying it to your next new feature — no full rewrite required.
References
Rate Limiting — Controlling API Traffic in Distributed Systems
Vue Vapor Mode — Eliminating Virtual DOM for 3x Performance
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.