.NET & C# Development · Lesson 57 of 92
Vertical Slice — One Feature, One Folder, Zero Friction
The Problem With Horizontal Layers
Classic layered architecture groups files by technical role:
src/
Controllers/
OrdersController.cs
ProductsController.cs
Services/
OrderService.cs
ProductService.cs
Repositories/
OrderRepository.cs
ProductRepository.cs
Models/
Order.cs
Product.csTo add a "place order" feature you touch four folders. To understand it you jump between four files. Layers create coupling between unrelated features: OrderService and ProductService live in the same layer, sharing visibility into each other's internals even though they have no business relationship.
Vertical Slices: One Folder Per Feature
Vertical Slice Architecture (VSA) groups everything for one use case together:
src/
Features/
Orders/
PlaceOrder/
PlaceOrderCommand.cs
PlaceOrderHandler.cs
PlaceOrderValidator.cs
PlaceOrderResponse.cs
GetOrders/
GetOrdersQuery.cs
GetOrdersHandler.cs
GetOrdersResponse.cs
CancelOrder/
CancelOrderCommand.cs
CancelOrderHandler.cs
Products/
CreateProduct/
...
GetProductById/
...
Shared/
Domain/
Order.cs
Product.cs
Persistence/
AppDbContext.cs
Program.csNow adding a feature means adding a folder. Reading a feature means reading one folder.
A Complete Feature Slice
Everything for "place order" lives in Features/Orders/PlaceOrder/:
// PlaceOrderCommand.cs
public record PlaceOrderCommand(
Guid CustomerId,
List<OrderLineDto> Lines) : IRequest<PlaceOrderResponse>;
public record OrderLineDto(Guid ProductId, int Quantity);
public record PlaceOrderResponse(Guid OrderId, decimal Total);// PlaceOrderValidator.cs
public class PlaceOrderValidator : AbstractValidator<PlaceOrderCommand>
{
public PlaceOrderValidator()
{
RuleFor(x => x.CustomerId).NotEmpty();
RuleFor(x => x.Lines).NotEmpty().WithMessage("Order must have at least one line.");
RuleForEach(x => x.Lines).ChildRules(line =>
{
line.RuleFor(l => l.ProductId).NotEmpty();
line.RuleFor(l => l.Quantity).GreaterThan(0);
});
}
}// PlaceOrderHandler.cs
public class PlaceOrderHandler : IRequestHandler<PlaceOrderCommand, PlaceOrderResponse>
{
private readonly AppDbContext _db;
public PlaceOrderHandler(AppDbContext db) => _db = db;
public async Task<PlaceOrderResponse> Handle(
PlaceOrderCommand request,
CancellationToken ct)
{
var productIds = request.Lines.Select(l => l.ProductId).ToList();
var products = await _db.Products
.Where(p => productIds.Contains(p.Id))
.ToDictionaryAsync(p => p.Id, ct);
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = request.CustomerId,
CreatedAt = DateTime.UtcNow,
Status = OrderStatus.Pending,
Lines = request.Lines.Select(l => new OrderLine
{
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitPrice = products[l.ProductId].Price
}).ToList()
};
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct);
return new PlaceOrderResponse(order.Id, order.Lines.Sum(l => l.UnitPrice * l.Quantity));
}
}The endpoint wires it up:
// Minimal API endpoint — could also live in a controller
app.MapPost("/orders", async (
PlaceOrderCommand command,
IMediator mediator,
CancellationToken ct) =>
{
var result = await mediator.Send(command, ct);
return Results.Created($"/orders/{result.OrderId}", result);
})
.WithName("PlaceOrder")
.WithTags("Orders");Cross-Slice Shared Code
Not everything belongs in a slice. True shared concerns go in Shared/:
- Domain entities (
Order,Product) — used by many slices AppDbContext— infrastructure shared across all- Common response types (
PagedResult<T>) - Extension methods and utilities
The rule: if it's used by exactly one slice, it lives in that slice. If two or more slices need it, it moves to Shared/.
Registering Features With MediatR
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssemblyContaining<Program>());
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
// Validation pipeline behavior — applies to every command/query
builder.Services.AddTransient(
typeof(IPipelineBehavior<,>),
typeof(ValidationBehavior<,>));No service registrations per feature. MediatR scans and wires handlers automatically.
VSA vs Clean Architecture
| Concern | Clean Architecture | Vertical Slice | |---|---|---| | Folder structure | By layer (Domain, Application, Infrastructure) | By feature | | Ceremony | High — interfaces, abstractions, mapping layers | Low — handler talks directly to DbContext | | Coupling between features | Low — enforced by layer boundaries | Low — enforced by folder boundaries | | Onboarding a feature | Touch multiple projects/layers | One folder | | Testability | Good — interfaces everywhere | Good — handler is a plain class | | When it shines | Complex domain with rich business rules | CRUD-heavy apps, feature teams, rapid iteration |
VSA doesn't forbid abstractions — it just doesn't mandate them. If a handler has complex domain logic, extract it to a domain service. If you need to swap databases, add a repository interface. You add abstractions when you feel the pain, not upfront.
When to Choose VSA vs Clean Architecture
Choose VSA when:
- Your app is primarily read/write operations without complex domain invariants
- You want fast onboarding — new developers find the feature, they find everything
- Feature teams own separate slices with minimal cross-team coordination
- You're building an API-first backend without a rich domain model
Choose Clean Architecture when:
- The domain has complex invariants, aggregates, and domain events
- You need strict boundaries to prevent infrastructure concerns from leaking into domain logic
- The team is large and layer-based separation aligns with team structure
- You have a DDD-driven design with bounded contexts that map to separate projects
VSA and Clean Architecture are not mutually exclusive — many production codebases start with VSA slices that internally follow clean separation where complexity demands it.