Vertical Slice Architecture
After five years building production systems, I've learned that organizing code by technical layer—controllers in one folder, services in another, repositories in a third—creates friction when requirements change. Vertical slice architecture flips this upside down: organize by feature instead, bundling the request handler, validator, endpoint, and data access logic into a single folder. This approach cuts across traditional layers, and when a requirement changes, you're editing one folder instead of hunting through five.
The Problem with Horizontal Layers
Most ASP.NET Core projects follow the layered architecture pattern. You have a Controllers folder, a Services folder, a Repositories folder, and a Models folder. When you build a feature—say, "create a new product"—the code scatters across all these locations. A change to validation logic means updating the controller, the service, the validator, and possibly the repository. This horizontal split works for simple projects, but as your domain grows, navigation becomes expensive and context-switching drains productivity.
I noticed this pain point while working on a ride-hailing platform where we had dozens of features. A small change to how we calculated driver earnings required touching the controller, a service class, two repositories, and three different validator files. The scattered dependencies made it hard to reason about what code actually belonged together.
Vertical slice architecture solves this by organizing around features instead of technical concerns.
Each Slice: Request, Handler, Validator, Endpoint
A vertical slice bundles everything needed for one feature into a single folder. For a "Create Product" feature, you'd have:
- Request: Data transfer object defining input parameters
- Handler: Business logic that processes the request
- Validator: Fluent validation rules for the request
- Endpoint: Minimal API route definition
Here's a concrete example. In a SaaS platform I built, we needed a feature to invite users to a workspace. The folder structure looked like this:
Features/
InviteUserToWorkspace/
InviteUserRequest.cs
InviteUserHandler.cs
InviteUserValidator.cs
InviteUserEndpoint.csThe request defines what the API accepts:
public class InviteUserRequest
{
public string Email { get; set; }
public string WorkspaceId { get; set; }
public string Role { get; set; }
}The validator uses FluentValidation:
public class InviteUserValidator : AbstractValidator
{
public InviteUserValidator()
{
RuleFor(x => x.Email).NotEmpty().EmailAddress();
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.Role).Must(r => new[] { "Admin", "Member" }.Contains(r));
}
}The handler contains the core logic:
public class InviteUserHandler
{
private readonly IWorkspaceRepository _workspaceRepo;
private readonly IEmailService _emailService;
public InviteUserHandler(IWorkspaceRepository workspaceRepo, IEmailService emailService)
{
_workspaceRepo = workspaceRepo;
_emailService = emailService;
}
public async Task Handle(InviteUserRequest request)
{
var workspace = await _workspaceRepo.GetByIdAsync(request.WorkspaceId);
if (workspace == null)
throw new NotFoundException("Workspace not found");
var invitation = workspace.InviteUser(request.Email, request.Role);
await _workspaceRepo.SaveAsync(workspace);
await _emailService.SendInvitationAsync(request.Email, invitation.Token);
return new InviteUserResponse { InvitationId = invitation.Id };
}
}Finally, the endpoint registers the route with minimal APIs:
public static class InviteUserEndpoint
{
public static void MapInviteUserEndpoint(this WebApplication app)
{
app.MapPost("/api/workspaces/{workspaceId}/invitations", Handle)
.WithName("InviteUserToWorkspace")
.WithOpenApi();
}
private static async Task Handle(
string workspaceId,
InviteUserRequest request,
InviteUserValidator validator,
InviteUserHandler handler)
{
var validationResult = await validator.ValidateAsync(request);
if (!validationResult.IsValid)
return Results.BadRequest(validationResult.Errors);
var response = await handler.Handle(request);
return Results.Created($"/api/invitations/{response.InvitationId}", response);
}
}Every piece of this feature lives in one folder. When requirements change, you open one directory.
Changes Touch One Folder Instead of Five
This is where vertical slices shine. On a trading platform I worked on, we needed to add a fee structure to order creation. With horizontal layers, I would have modified:
- The OrderController to accept fee inputs
- The CreateOrderValidator to validate fee ranges
- The OrderService to calculate fees
- The OrderRepository to persist fee data
- The Order entity to include fee fields
With vertical slices, the entire change was in one folder: Features/CreateOrder/. I added fee validation to CreateOrderValidator.cs, updated the request class, modified the handler's business logic, and that was it. No hunting through five files.
This cohesion reduces bugs. When all related code lives together, it's easier to spot missing logic or inconsistent behavior. A junior developer can understand the complete flow by reading one folder instead of tracing through layers.
There's also a secondary benefit: easier deletion. When a feature is deprecated, you delete the entire folder. With horizontal layers, you're never quite sure if you've removed all the code because it's spread out.
Slices Can Use Different Data Access Styles
One misconception about vertical slices is that every feature must use the same data access pattern. That's not true, and it's actually one of the architecture's strengths.
In the ride-hailing app, most features used Entity Framework Core with repositories. But the driver earnings slice—which needed to query complex aggregations across multiple tables—used raw SQL and Dapper. We could do this without creating architectural friction because each slice owns its data access layer.
Here's what that looked like:
// Features/CalculateDriverEarnings/
public class CalculateEarningsHandler
{
private readonly IDbConnection _connection;
public CalculateEarningsHandler(IDbConnection connection)
{
_connection = connection;
}
public async Task Handle(CalculateEarningsRequest request)
{
// Using Dapper for complex aggregations
const string query = @"
SELECT
r.Id,
r.PickupLocation,
r.DropoffLocation,
r.Amount,
r.CreatedAt,
(r.Amount * 0.75) - 2.50 as DriverEarnings
FROM Rides r
WHERE r.DriverId = @DriverId
AND r.CreatedAt >= @StartDate
AND r.CreatedAt <= @EndDate
";
var earnings = await _connection.QueryAsync(
query,
new { request.DriverId, request.StartDate, request.EndDate });
return new EarningsReport { Rides = earnings.ToList() };
}
}Meanwhile, another slice might use EF Core with a repository pattern. This flexibility lets you choose the right tool for each job without forcing a one-size-fits-all approach.
Pairs Perfectly with Minimal APIs
Vertical slices and minimal APIs are natural partners. Minimal APIs allow you to define endpoints close to your handler logic, keeping the feature cohesion tight.
Traditional MVC controllers scatter across the codebase. Minimal APIs let you define routes right inside your endpoint class, making the entire feature self-contained:
// Features/Products/CreateProduct/CreateProductEndpoint.cs
public static class CreateProductEndpoint
{
public static void Map(WebApplication app)
{
app.MapPost("/api/products", Handle)
.RequireAuthorization()
.WithName("CreateProduct")
.WithOpenApi()
.Produces(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest);
}
private static async Task Handle(
CreateProductRequest request,
CreateProductValidator validator,
CreateProductHandler handler,
ILogger logger)
{
logger.LogInformation("Creating product: {ProductName}", request.Name);
var validationResult = await validator.ValidateAsync(request);
if (!validationResult.IsValid)
return Results.ValidationProblem(
validationResult.Errors.ToDictionary(e => e.PropertyName, e => new[] { e.ErrorMessage }));
var response = await handler.Handle(request);
return Results.Created($"/api/products/{response.ProductId}", response);
}
}
// In Program.cs, you register all endpoints:
var assembly = typeof(Program).Assembly;
var endpointTypes = assembly
.GetTypes()
.Where(t => t.Name.EndsWith("Endpoint"));
foreach (var type in endpointTypes)
{
var method = type.GetMethod("Map", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
method?.Invoke(null, new object[] { builder.Build() });
}This approach keeps your Program.cs clean while maintaining feature isolation. Each feature registers itself, and your startup code remains declarative and simple.
Common Mistakes
Sharing handlers across slices: Don't create a "shared handlers" folder. Each feature's handler should be self-contained. If two features have similar logic, extract the business logic into a domain service or utility, not a shared handler.
Creating slices that are too broad: A slice should represent one business action, not an entire domain. "CreateProduct" is a slice. "Products" is a domain that contains multiple slices: CreateProduct, UpdateProduct, ListProducts, DeleteProduct.
Forcing all data access into repositories: Some features don't need repositories. If you're executing a single query, use EF Core directly in the handler. Repositories add abstraction; make sure you need that abstraction before adding it.
Mixing request models with domain models: Keep your API request DTOs separate from your domain entities. A request might accept "email" while your domain model uses "emailAddress". This separation protects your domain from API concerns.
Ignoring cross-cutting concerns: Logging, authentication, and validation still need to be centralized. Use middleware for authentication, use a validation pipeline for common validation rules, and use dependency injection to inject logging everywhere.
Key Takeaways
- Organize by feature, not by technical layer. Each feature lives in its own folder with request, handler, validator, and endpoint classes.
- When requirements change, you modify one folder instead of hunting through five different locations across the codebase.
- Each slice can use different data access patterns—one feature might use EF Core while another uses Dapper, and that's perfectly fine.
- Minimal APIs pair naturally with vertical slices, keeping endpoints registered close to their handlers and maintaining feature cohesion.
- Slices are easier to understand, test, and delete because all related code lives together in a single place.
- Don't create shared handlers or force patterns that don't fit the feature. Let each slice be pragmatic about its implementation.






Comments
Loading comments…