Vertical Slices: Organizing Code by Feature, Not by Layer
A practical guide to Vertical Slice Architecture — structuring an application around complete features rather than horizontal technical layers — covering the core idea, folder structure, the MediatR-based implementation pattern in .NET, how slices relate to DDD and Clean/Onion Architecture, testing implications, and honest guidance on where this approach fits.
Table of Contents
- Introduction
- The Layered Architecture This Reacts Against
- What a Vertical Slice Actually Is
- Folder Structure in Practice
- Implementing Slices with MediatR
- A Complete Worked Example
- Handling Shared Code Without Recreating Layers
- Vertical Slices and CQRS
- Vertical Slices and Domain-Driven Design
- Vertical Slices and Clean/Onion Architecture
- Testing Implications
- Vertical Slices Within a Microservice, and As an Alternative To One
- When Vertical Slices Are (and Aren't) the Right Fit
- Common Pitfalls
- Quick Reference Table
- Conclusion
Introduction
Vertical Slice Architecture organizes code around individual features — each feature owning everything it needs, end to end, from its API endpoint through its business logic to its data access — rather than organizing code around technical layers shared across every feature (a single, monolithic "Controllers" folder, a single "Services" folder, a single "Repositories" folder). The name comes directly from how it looks when drawn: instead of horizontal layers stacked on top of each other, each feature is a vertical slice cutting straight through every layer it needs.
Layered (horizontal): Vertical Slices:
┌─────────────────────────┐ ┌────────┐┌────────┐┌────────┐
│ Controllers │ │ Place ││ Cancel ││ Get │
├─────────────────────────┤ │ Order ││ Order ││ Order │
│ Services │ │ ││ ││ │
├─────────────────────────┤ │ (API + ││ (API + ││ (API + │
│ Repositories │ │ logic +││ logic +││ logic +│
├─────────────────────────┤ │ data) ││ data) ││ data) │
│ Database │ └────────┘└────────┘└────────┘
└─────────────────────────┘
This guide covers the core idea, how to actually implement it in .NET (commonly via MediatR), and — as with every architectural pattern covered in this series — where it genuinely helps and where it doesn't.
1. The Layered Architecture This Reacts Against
The traditional n-tier structure
Controllers/
OrdersController.cs
ProductsController.cs
CustomersController.cs
Services/
OrderService.cs
ProductService.cs
CustomerService.cs
Repositories/
OrderRepository.cs
ProductRepository.cs
CustomerRepository.cs
Most ASP.NET Core tutorials and a large share of real production codebases organize projects this way — every feature contributes one class to each layer, and each layer's folder becomes a flat collection of classes belonging to entirely unrelated features, related to each other only by the technical role they play (all controllers together, all services together).
Why this becomes genuinely painful as an application grows
"I need to change how order cancellation works."
Files touched:
Controllers/OrdersController.cs (one method, out of a dozen unrelated ones in this file)
Services/OrderService.cs (one method, out of a dozen unrelated ones in this file)
Repositories/OrderRepository.cs (one method, out of a dozen unrelated ones in this file)
A single, focused feature change — modifying how order cancellation works — requires touching three separate files, each of which is shared across every other order-related feature and contains a great deal of code entirely unrelated to the change at hand. As an application grows, OrderService.cs in particular tends to become an ever-larger, increasingly unwieldy file containing every piece of order-related business logic the application has ever needed, regardless of how unrelated those individual pieces of logic actually are to each other.
The cross-cutting coupling this structure creates
public class OrderService
{
private readonly IOrderRepository _orderRepository;
private readonly IInventoryService _inventoryService;
private readonly IEmailService _emailService;
private readonly IPaymentService _paymentService;
// every method in this class potentially depends on every one of these,
// even though any single method (like "GetOrderById") might need only one
public async Task<Order> GetOrderByIdAsync(int id) => await _orderRepository.GetByIdAsync(id);
public async Task PlaceOrderAsync(PlaceOrderRequest request) { /* uses ALL the injected dependencies */ }
public async Task CancelOrderAsync(int id) { /* uses a different subset */ }
}
A shared service class accumulates dependencies for the union of everything every method inside it needs — meaning a simple read operation (GetOrderByIdAsync) ends up constructed with dependencies (IPaymentService, IEmailService) it never actually uses, purely because it happens to live in the same class as methods that do need them. This isn't a fatal flaw, but it's exactly the kind of accidental coupling vertical slices are designed to eliminate.
2. What a Vertical Slice Actually Is
One feature, everything it needs, together
Features/
Orders/
PlaceOrder/
PlaceOrderEndpoint.cs
PlaceOrderCommand.cs
PlaceOrderHandler.cs
PlaceOrderValidator.cs
CancelOrder/
CancelOrderEndpoint.cs
CancelOrderCommand.cs
CancelOrderHandler.cs
GetOrderById/
GetOrderByIdEndpoint.cs
GetOrderByIdQuery.cs
GetOrderByIdHandler.cs
Each feature — PlaceOrder, CancelOrder, GetOrderById — gets its own folder containing everything specific to that one feature: the API endpoint definition, the request/command shape, the handler containing the actual logic, and (where relevant) its own validation. Nothing in CancelOrder's folder is shared with PlaceOrder's folder unless it's genuinely, deliberately shared (Section 6).
The core principle: minimize coupling between features, accept some duplication within them
// PlaceOrderHandler.cs
public async Task<Result> Handle(PlaceOrderCommand command)
{
var order = new Order(command.CustomerId);
// ... builds the order, saves it directly via DbContext or a repository specific to this need
}
// CancelOrderHandler.cs
public async Task<Result> Handle(CancelOrderCommand command)
{
var order = await _dbContext.Orders.FindAsync(command.OrderId);
// ... its own, independent data access, not routed through a shared "OrderService"
}
This is the deliberate, sometimes counter-intuitive trade-off at the heart of vertical slices: two handlers that both need to load an Order might each write their own, slightly different query, rather than both being forced through one shared OrderRepository.GetByIdAsync() method that has to serve every possible caller's needs equally well. A small amount of duplication between slices is accepted as the cost of each slice being genuinely independent and easy to reason about within itself — directly connecting to this series' Microservices guide's emphasis on independent deployability, applied here at the level of a single application's internal feature organization rather than across separate services.
Why "accept some duplication" is a genuine, deliberate design stance, not laziness
The traditional layered approach optimizes hard against duplication — one shared OrderRepository, reused everywhere — but that shared abstraction has to serve every caller's needs, which is exactly what makes it grow large and tangled over time (Section 1). Vertical slices trade a small amount of code duplication for the ability to change one feature without needing to understand or risk affecting how every other feature uses the same shared class — a trade-off that tends to pay off specifically as the number of genuinely distinct features in a codebase grows.
3. Folder Structure in Practice
Organizing by feature, not by technical role
MyApp/
Features/
Orders/
PlaceOrder/
CancelOrder/
GetOrderById/
ListOrdersForCustomer/
Products/
CreateProduct/
UpdateProductPrice/
SearchProducts/
Customers/
RegisterCustomer/
UpdateCustomerProfile/
Common/ # deliberately, explicitly shared code — see Section 6
Program.cs
A quick, telling test of this structure: opening the Orders folder immediately tells you every distinct thing the application can do with orders — PlaceOrder, CancelOrder, GetOrderById — which is a meaningfully different, arguably more useful piece of information than a layered structure's OrderService.cs file, which tells you nothing about the application's actual capabilities until you've read through its entire contents.
Grouping slices loosely by area, without forcing a rigid hierarchy
Features/
Orders/ # a loose grouping — not a bounded context boundary the way DDD would define one (Section 8)
PlaceOrder/
CancelOrder/
Reporting/
GenerateMonthlySalesReport/ # this slice might read from Orders' data, but lives in its own area
The top-level grouping (Orders, Products, Reporting) is a loose organizational convenience, not a strict architectural boundary the way a DDD bounded context or a microservice boundary would be — a slice under Reporting reading data that conceptually "belongs to" the Orders area is entirely normal and doesn't violate anything, since vertical slices are primarily about organizing code, not necessarily about enforcing the same kind of hard data-ownership boundaries covered in this series' Microservices and DDD guides (though the two ideas are compatible and often used together, per Sections 8 and 11).
4. Implementing Slices with MediatR
Why MediatR is the common implementation vehicle in .NET
public record PlaceOrderCommand(int CustomerId, List<OrderItemDto> Items) : IRequest<Result<int>>;
public class PlaceOrderHandler : IRequestHandler<PlaceOrderCommand, Result<int>>
{
private readonly AppDbContext _dbContext;
public PlaceOrderHandler(AppDbContext dbContext) => _dbContext = dbContext;
public async Task<Result<int>> Handle(PlaceOrderCommand command, CancellationToken cancellationToken)
{
var order = new Order(command.CustomerId);
foreach (var item in command.Items)
order.AddLineItem(item.ProductId, item.UnitPrice, item.Quantity);
_dbContext.Orders.Add(order);
await _dbContext.SaveChangesAsync(cancellationToken);
return Result.Success(order.Id);
}
}
MediatR (a widely used, lightweight in-process mediator library for .NET) has become the de facto standard vehicle for implementing vertical slices — each slice becomes one IRequest/command or query, paired with exactly one IRequestHandler containing that slice's entire logic. This isn't a strict requirement (Section 12 covers alternatives), but MediatR's one-request-one-handler model maps naturally onto "one feature, one focused piece of code," and its built-in pipeline behaviors (Section 6) provide a clean mechanism for genuinely cross-cutting concerns without recreating shared layers.
Wiring it into a Minimal API endpoint
public static class PlaceOrderEndpoint
{
public static void MapPlaceOrder(this IEndpointRouteBuilder app)
{
app.MapPost("/orders", async (PlaceOrderCommand command, IMediator mediator) =>
{
var result = await mediator.Send(command);
return result.IsSuccess ? Results.Created($"/orders/{result.Value}", result.Value) : Results.BadRequest(result.Error);
});
}
}
// Program.cs
app.MapPlaceOrder();
app.MapCancelOrder();
app.MapGetOrderById();
This pairs naturally with the Minimal APIs pattern covered in this series' companion guide — each slice's endpoint mapping lives in its own file, right alongside its command and handler, and Program.cs simply calls each slice's own Map... extension method, keeping the central startup file from becoming a long, unstructured list of route definitions.
Registering MediatR and discovering handlers automatically
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
MediatR's assembly-scanning registration automatically discovers every IRequestHandler implementation in the project — adding a new slice means adding a new command/handler pair in its own folder, with no central registration list to remember to update, which is itself a small but genuine expression of the "each slice is self-contained" principle.
5. A Complete Worked Example
The CancelOrder slice, in full
// Features/Orders/CancelOrder/CancelOrderCommand.cs
public record CancelOrderCommand(int OrderId) : IRequest<Result>;
// Features/Orders/CancelOrder/CancelOrderValidator.cs
public class CancelOrderValidator : AbstractValidator<CancelOrderCommand>
{
public CancelOrderValidator()
{
RuleFor(x => x.OrderId).GreaterThan(0);
}
}
// Features/Orders/CancelOrder/CancelOrderHandler.cs
public class CancelOrderHandler : IRequestHandler<CancelOrderCommand, Result>
{
private readonly AppDbContext _dbContext;
public CancelOrderHandler(AppDbContext dbContext) => _dbContext = dbContext;
public async Task<Result> Handle(CancelOrderCommand command, CancellationToken cancellationToken)
{
var order = await _dbContext.Orders.FindAsync(new object[] { command.OrderId }, cancellationToken);
if (order is null) return Result.Failure("Order not found");
try
{
order.Cancel(); // the DDD-style aggregate enforcing its own rule — per this series' DDD guide
}
catch (InvalidOperationException ex)
{
return Result.Failure(ex.Message);
}
await _dbContext.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}
// Features/Orders/CancelOrder/CancelOrderEndpoint.cs
public static class CancelOrderEndpoint
{
public static void MapCancelOrder(this IEndpointRouteBuilder app)
{
app.MapPost("/orders/{orderId}/cancel", async (int orderId, IMediator mediator) =>
{
var result = await mediator.Send(new CancelOrderCommand(orderId));
return result.IsSuccess ? Results.NoContent() : Results.BadRequest(result.Error);
});
}
}
Four small, focused files, all living in one folder, together containing everything needed to understand, test, and modify order cancellation — no other feature's code needs to be read or understood to work confidently on this one, and this slice's own files contain nothing relevant to any other feature.
Notice what this example deliberately does and doesn't do
It uses AppDbContext directly rather than a generic IOrderRepository abstraction (Section 6 covers this choice explicitly), it calls order.Cancel() — a DDD-style aggregate method enforcing its own business rule, per this series' DDD guide — rather than checking and setting a status flag inline in the handler, and it returns a Result type rather than throwing exceptions for expected, "this can legitimately fail" business outcomes, all choices that are common, idiomatic conventions within the vertical slice community, though none of them are strictly required by the pattern itself.
6. Handling Shared Code Without Recreating Layers
The genuine tension: some code really is shared, and that's fine
Vertical slices don't mean zero shared code — they mean being deliberate about what's genuinely, unavoidably shared (a DbContext, a domain entity like Order itself, cross-cutting concerns like logging or validation) versus what was only shared because the layered architecture forced every feature through the same service/repository classes even when their actual needs diverged.
Domain entities are shared, and that's expected
// Features/Orders/PlaceOrder/PlaceOrderHandler.cs — uses the Order entity
// Features/Orders/CancelOrder/CancelOrderHandler.cs — uses the SAME Order entity
The Order aggregate itself (per this series' DDD guide) is legitimately shared across every order-related slice — vertical slices are about not sharing the orchestration code (services, generic repositories) across features, not about duplicating the actual domain model every feature operates on. A shared Order.cs domain class, referenced from many slices, is entirely consistent with vertical slice architecture.
MediatR pipeline behaviors for genuine cross-cutting concerns
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
foreach (var validator in _validators)
{
var result = await validator.ValidateAsync(request, cancellationToken);
if (!result.IsValid) throw new ValidationException(result.Errors);
}
return await next();
}
}
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
MediatR's pipeline behaviors run automatically around every request, regardless of which slice it belongs to — this is the correct mechanism for genuinely cross-cutting concerns (validation, logging, transaction management, authorization checks) that every slice needs, without requiring every individual handler to remember to call a shared validation service manually, and without recreating a shared "layer" every feature has to be explicitly routed through.
A Common folder for genuinely reusable building blocks
Common/
Result.cs # a shared Result<T> type, used by every slice's return type
AppDbContext.cs # the shared EF Core context, per this series' EF Core guide
ValidationBehavior.cs # the MediatR pipeline behavior shown above
Utility types genuinely used everywhere (a Result<T> wrapper, the DbContext itself, pipeline behaviors) belong in an explicit, small, deliberately-curated Common folder — the discipline is keeping this folder small and genuinely general-purpose, resisting the pull to let it slowly regrow into the same kind of catch-all "Services" folder vertical slices were adopted specifically to avoid.
7. Vertical Slices and CQRS
A natural, common pairing, though not a requirement
PlaceOrderCommand → a COMMAND — changes state, per this series' Event-Driven Architecture guide's CQRS discussion
GetOrderByIdQuery → a QUERY — reads state, potentially from a different, read-optimized model entirely
Vertical slices pair especially naturally with CQRS (covered in this series' Event-Driven Architecture guide) because MediatR already distinguishes commands (IRequest<Result>) from queries (IRequest<OrderDto>) at the type level — each slice is already, by construction, either a command or a query, making the CQRS split something that falls out of the vertical-slice structure almost for free, rather than requiring separate architectural effort to introduce.
Queries can bypass the domain model entirely
public class GetOrderByIdHandler : IRequestHandler<GetOrderByIdQuery, OrderDto?>
{
private readonly AppDbContext _dbContext;
public async Task<OrderDto?> Handle(GetOrderByIdQuery query, CancellationToken cancellationToken)
{
return await _dbContext.Orders
.Where(o => o.Id == query.OrderId)
.Select(o => new OrderDto(o.Id, o.Status.ToString(), o.Total)) // projects directly, per this series' EF Core guide
.FirstOrDefaultAsync(cancellationToken);
}
}
As covered in this series' EF Core guide, projecting a query directly into a DTO (rather than loading a full Order aggregate and mapping it afterward) is both a performance optimization and a natural fit for vertical slices' query handlers specifically — a query slice has no business rules to enforce (it's read-only), so there's no need to route it through the domain model at all, unlike a command slice, which typically does need to load and operate on the aggregate to enforce its business rules correctly.
8. Vertical Slices and Domain-Driven Design
Complementary, operating at different levels
As covered in this series' DDD guide, DDD's tactical patterns (aggregates, value objects, domain events) describe how to model the actual business logic correctly; vertical slices describe how to organize the code that orchestrates and exposes that logic — the two are complementary, not competing, and combine naturally: each vertical slice's handler typically loads a DDD aggregate, calls a business-rule-enforcing method on it (order.Cancel(), per Section 5's example), and persists the result.
Slices don't need to align one-to-one with aggregates, or with bounded contexts
Order aggregate → used by PlaceOrder, CancelOrder, and GetOrderById slices — MANY slices, ONE aggregate
A single DDD aggregate is commonly used by several different vertical slices (every operation that touches an Order), and a single bounded context (per this series' DDD guide) commonly contains many vertical slices — the two concepts operate at genuinely different granularities: a bounded context is a boundary around a model; a vertical slice is a boundary around one specific use case operating within that model.
Where the domain model lives relative to slices
Features/
Orders/
PlaceOrder/
CancelOrder/
Domain/ # or, alternatively, a small shared "Domain" project/folder
Order.cs # the DDD aggregate itself — shared across every Orders slice
A common, pragmatic structure keeps the actual DDD domain model (the Order aggregate, its value objects) in its own small, shared area — distinct from both the individual feature slices that use it and the small Common folder from Section 6 — reflecting that the domain model is genuinely, deliberately shared across every slice that operates on that particular aggregate, while each slice's own orchestration code around it remains independent.
9. Vertical Slices and Clean/Onion Architecture
The tension vertical slices push back against
Clean Architecture and Onion Architecture (both widely taught, layer-based architectural styles emphasizing dependency inversion — outer layers depend on inner layers, never the reverse) share DNA with the traditional layered structure from Section 1, just with the dependency direction made more explicit and rigorous. Vertical slices emerged partly as a reaction to how, in practice, Clean/Onion Architecture's emphasis on abstraction layers (repository interfaces, service interfaces, use-case interactors) can produce exactly the same kind of indirection-heavy, hard-to-navigate codebase this guide's Section 1 describes, even while technically respecting the dependency-inversion principle correctly.
They can be combined, at different granularities
Overall solution structure: Clean Architecture's layers (Domain, Application, Infrastructure, Presentation)
Within the Application layer specifically: organized as vertical slices, not as generic "Services"
A genuinely common, pragmatic combination: keep Clean Architecture's outer boundary (Domain has no dependencies; Infrastructure depends on Domain and Application; Presentation depends on Application) for the solution's overall project structure, but organize the Application layer's actual use cases as vertical slices (one folder per feature, each with its own MediatR command/handler) rather than as generic service classes — getting Clean Architecture's dependency discipline at the macro level while avoiding its common "everything is an abstraction over an abstraction" navigability problem at the micro level.
The honest disagreement in the wider .NET community
It's worth being upfront that there's genuine, ongoing debate in the .NET community about whether combining these two approaches is the best of both worlds or an unnecessary compromise diluting each — some practitioners advocate for vertical slices largely replacing the need for Clean Architecture's more elaborate layering and abstraction, arguing that a well-organized set of slices, each depending directly on AppDbContext and the domain model (as in Section 5's example, deliberately skipping a generic repository abstraction), achieves comparable testability and maintainability with meaningfully less ceremony.
10. Testing Implications
Testing a slice in isolation
[Fact]
public async Task CancelOrder_Fails_WhenOrderNotFound()
{
var dbContext = CreateInMemoryDbContext(); // per this series' EF Core guide's testing section
var handler = new CancelOrderHandler(dbContext);
var result = await handler.Handle(new CancelOrderCommand(999), CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.Equal("Order not found", result.Error);
}
Because a slice's handler contains everything relevant to that one feature (Section 5), testing it requires understanding and setting up context for exactly that one feature — no need to mock or reason about an OrderService's dozen other unrelated methods, or worry that a change to this test's setup might affect an entirely unrelated feature's tests that happen to exercise the same shared service class.
Fewer, more meaningful mocks
// A layered architecture's OrderService test often needs to mock MANY collaborators:
var mockRepo = new Mock<IOrderRepository>();
var mockInventory = new Mock<IInventoryService>();
var mockEmail = new Mock<IEmailService>();
var mockPayment = new Mock<IPaymentService>();
// even though THIS specific test only cares about one code path
// A vertical slice handler often needs only what IT actually depends on:
var dbContext = CreateInMemoryDbContext(); // that's often the whole setup
Because a slice's handler depends only on what that specific slice actually needs (rather than inheriting the union of every method's dependencies the way a shared service class does, per Section 1), tests tend to require meaningfully less mock setup — a direct, practical consequence of the reduced coupling vertical slices are designed to produce.
Integration testing a slice end-to-end
[Fact]
public async Task CancelOrder_ReturnsNoContent_ForExistingOrder()
{
await using var factory = new WebApplicationFactory<Program>(); // per this series' ASP.NET Core guide
var client = factory.CreateClient();
var response = await client.PostAsync("/orders/1/cancel", null);
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
}
The WebApplicationFactory-based integration testing pattern covered in this series' ASP.NET Core guide applies identically to a vertical-slice-organized application — each slice's endpoint can be exercised end-to-end (through the real middleware pipeline, the real MediatR dispatch, and, ideally, a real or realistically-mocked database via Testcontainers, per this series' EF Core guide) independently of every other slice.
11. Vertical Slices Within a Microservice, and As an Alternative To One
Vertical slices as internal organization within a single microservice
As referenced throughout this series' Microservices guide, a well-bounded microservice still needs internal organization — vertical slices are a natural, common choice for structuring the code within one microservice, complementing rather than competing with the service-level boundaries that guide covers (a single OrderService microservice might internally be organized into PlaceOrder, CancelOrder, and GetOrderById slices).
Vertical slices as an alternative to splitting into microservices prematurely
A "modular monolith" (per this series' Microservices guide's "monolith first" guidance)
organized as vertical slices, grouped loosely by bounded context (per this series' DDD guide)
= most of microservices' organizational clarity, without the distributed-systems cost
This connects directly and deliberately to this series' Microservices guide's "monolith first" recommendation — a single-deployable application, internally organized as vertical slices grouped by bounded context, gives much of the same clarity and low-coupling benefit microservices provide (each feature is easy to find, understand, and modify independently) without yet paying the real operational cost of network calls, distributed data, and independent deployment pipelines covered throughout that guide. If and when a genuine need for actual service separation emerges, a codebase already organized this way is considerably easier to split — each vertical slice folder is already close to self-contained, making it a natural seam to extract into its own service later.
12. When Vertical Slices Are (and Aren't) the Right Fit
Where this approach clearly earns its keep
- Applications with many, genuinely distinct features — the more features a codebase has, the more a shared-service-class approach tends to accumulate unrelated logic in one place, and the more vertical slices' per-feature isolation pays off.
-
Teams where multiple people work on different features concurrently — since each slice is largely self-contained, the risk of two developers' changes to different features colliding in the same shared file (a common source of merge conflicts in the layered approach's shared
OrderService.cs) drops substantially. - Codebases planning an eventual move toward microservices — as covered in Section 11, this structure is a natural, low-regret stepping stone.
Where it may be more ceremony than the problem warrants
- A genuinely small application with few features — the overhead of MediatR, per-slice folders, and the associated file count may exceed what a small application's actual complexity justifies; a simpler, more direct structure can be entirely reasonable.
- A team unfamiliar with the pattern and under significant delivery pressure — the learning curve (MediatR conventions, the shift away from a "look for the Service class" mental model) is real, and introducing it without buy-in or time to adjust can create more friction than the benefit is worth in the short term.
It's an internal code-organization choice, not a fundamental architecture decision
Unlike the choice between a monolith and microservices (a genuinely hard-to-reverse decision with real operational consequences, per this series' Microservices guide), the choice between vertical slices and a traditional layered structure is comparatively low-stakes and reversible — it affects how code is organized within a codebase, not how the system is deployed or operated, which makes it a reasonable pattern to adopt experimentally on a subset of a codebase and expand from there, rather than requiring an all-or-nothing, hard-to-undo commitment upfront.
13. Common Pitfalls
| Pitfall | Why it hurts | Better approach |
|---|---|---|
| Introducing a generic repository abstraction inside every slice anyway | Recreates the exact shared-abstraction coupling vertical slices are meant to avoid | Let slices depend directly on DbContext (or a narrowly-scoped need) where that's genuinely simpler |
Letting the Common folder slowly regrow into a catch-all "Services" folder |
Reintroduces the original layered-architecture problem under a new name | Keep Common small and deliberately curated; question anything added to it |
| Duplicating genuinely complex business logic across slices instead of sharing the domain model | Multiple, subtly diverging implementations of the same actual business rule | Keep domain logic in shared aggregates/entities (per this series' DDD guide); duplicate only orchestration |
| Treating vertical slices as requiring MediatR specifically | Creates unnecessary resistance from teams who'd rather not adopt the library | The core idea works without MediatR too — feature folders with direct method calls are a valid implementation |
| No pipeline behaviors for cross-cutting concerns, leading to repeated validation/logging code in every handler | Recreates duplication for concerns that genuinely are cross-cutting | Use MediatR pipeline behaviors (or an equivalent) for validation, logging, and similar concerns |
| Adopting vertical slices for a genuinely small, simple application out of trend-following | Adds ceremony disproportionate to the actual complexity being managed | Match the pattern to genuine feature count and team-size complexity, per Section 12 |
| Assuming slice folders must map one-to-one onto bounded contexts or aggregates | Conflates two genuinely different organizational concerns (per Section 8) | Let a single aggregate/bounded context contain many slices, as is normal and expected |
Quick Reference Table
| Concept | Purpose |
|---|---|
| Vertical slice | One feature's complete code (API, logic, data access) organized together |
| Feature folder | The physical organization unit — one folder per slice |
| MediatR command/query + handler | The common .NET implementation vehicle for a single slice |
| Pipeline behavior | Cross-cutting concerns (validation, logging) applied uniformly without a shared layer |
Common folder |
Deliberately, narrowly shared code (Result types, DbContext, behaviors) |
| Shared domain model | Aggregates/entities used by many slices, distinct from per-slice orchestration code |
| Slice-level testing | Testing one handler in isolation, with minimal, focused mock setup |
| Modular monolith + vertical slices | A "monolith first" structure that's a natural stepping stone toward microservices |
Conclusion
Vertical Slice Architecture's core insight is that the traditional layered structure's shared "Services" and "Repositories" folders optimize for the wrong thing — minimizing code duplication across features, at the cost of coupling every feature to every other feature that happens to share the same class. Organizing by feature instead accepts a small amount of duplication in exchange for genuine independence: each slice can be understood, tested, and changed without needing to reason about a shared class's other, unrelated responsibilities.
This pattern connects naturally to nearly every other architectural guide in this series — it's commonly the internal organization within a microservice or a well-structured modular monolith (per this series' Microservices guide), it pairs naturally with CQRS's command/query split (per the Event-Driven Architecture guide) and with DDD's aggregates providing the actual business logic each slice orchestrates (per the DDD guide), and — because it's a comparatively low-stakes, reversible code-organization choice rather than a fundamental architectural commitment — it's one of the more approachable patterns in this series to actually try, even incrementally, on a real, existing codebase.
Found this useful? Feel free to star the repo, open an issue with corrections, or share the moment a bloated shared service class finally convinced you to try organizing by feature instead.
Top comments (0)