Ever crack open a legacy API and feel like you are rummaging through the kitchen junk drawer looking for scissors? That Services folder keeps getting taller. The Interfaces folder has opinions. And adding a small endpoint kicks off a tab-switching marathon that would make even Dwight Schrute schedule a performance review. There is a simpler way to build APIs that scale without the yak shave. Say hello to vertical slices.

What is a vertical slice

Vertical Slice Architecture organizes code by feature, not by technical layer. Instead of spreading an endpoint across Controllers, Services, Validators, DTOs, and Repositories, you keep everything a feature needs in one place. Think of each slice as a self contained unit that can be developed, tested, and deployed with minimal fuss.

  • Feature first: endpoints, handlers, DTOs, validators, and behaviors live together.
  • CQRS friendly: commands and queries are different shapes and can evolve independently.
  • Clean boundaries: fewer cross project changes when a feature changes.

I still like Clean Architecture for larger systems and domain heavy work. But when your API count grows, feature first slices reduce friction and make onboarding friendlier.

The tiny story: Michael Scott Paper quotes

Let’s build a minimal feature that creates a paper quote. One endpoint to create a quote and another to fetch it later. We will use Minimal APIs, MediatR for CQRS, and FluentValidation for guardrails.

Bootstrapping the app

We register MediatR, wire up validators, and add a validation pipeline so bad data never reaches our handlers. Keep your Program.cs tidy and let features do the heavy lifting.

using FluentValidation;
using MediatR;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining<Program>());
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
var app = builder.Build();
app.Run();

Shape the feature: command and DTO

Commands change state. Queries read state. We will start with a command that creates a quote, plus a DTO for the result. Names are friendly so future you smiles when reading the code.

using MediatR;
public record QuoteDto(
Guid Id,
string Client,
int Sheets,
decimal Total);
public record CreateQuoteCommand(
string Client,
int Sheets,
decimal PricePerSheet) : IRequest<QuoteDto>;

Handle the command

Handlers are where the work happens. In the real world you would persist to a database. This tiny example calculates a total and returns a result. Paper beats rock, math beats hand waving.

using MediatR;
public class CreateQuoteHandler : IRequestHandler<CreateQuoteCommand, QuoteDto>
{
public Task<QuoteDto> Handle(CreateQuoteCommand c, CancellationToken _)
=> Task.FromResult(new QuoteDto(Guid.NewGuid(), c.Client, c.Sheets, c.Sheets * c.PricePerSheet));
}

Validate at the edge with FluentValidation

Bad input should not sneak past your guard tower. The validator ensures we do not create quotes for mystery clients or negative sheet counts. This keeps your handler focused on business rules.

using FluentValidation;
public class CreateQuoteValidator : AbstractValidator<CreateQuoteCommand>
{
public CreateQuoteValidator()
{
RuleFor(x => x.Client).NotEmpty();
RuleFor(x => x.Sheets).GreaterThan(0);
RuleFor(x => x.PricePerSheet).GreaterThan(0);
}
}

Run validation automatically with a pipeline behavior

A pipeline behavior can run before every handler. This keeps validation consistent and invisible to your features. No more copy and paste try catch parties.

using FluentValidation;
using MediatR;
public class ValidationBehavior<TReq, TRes> : IPipelineBehavior<TReq, TRes> {
readonly IEnumerable<IValidator<TReq>> _v;
public ValidationBehavior(IEnumerable<IValidator<TReq>> v) => _v = v;
public async Task<TRes> Handle(TReq r, RequestHandlerDelegate<TRes> next, CancellationToken ct) {
var ctx = new ValidationContext<TReq>(r);
var fails = _v.Select(v => v.Validate(ctx)).SelectMany(x => x.Errors).Where(e => e != null).ToList();
if (fails.Count > 0) throw new ValidationException(fails);
return await next();
}
}

Map the endpoint with Minimal APIs

Feature folders usually include the endpoint mapping right next to the command and handler. That means fewer file hops and a clearer mental model.

using MediatR;
app.MapPost("/quotes", async (CreateQuoteCommand cmd, ISender sender)
=> Results.Ok(await sender.Send(cmd)));

Add a query slice

CQRS shines when reads and writes take different paths. A query has a different shape than a command and can be optimized separately. In this sample we return null to keep things short.

using MediatR;
public record GetQuoteQuery(Guid Id) : IRequest<QuoteDto?>;
public class GetQuoteHandler : IRequestHandler<GetQuoteQuery, QuoteDto?>
{
public Task<QuoteDto?> Handle(GetQuoteQuery q, CancellationToken _)
=> Task.FromResult<QuoteDto?>(null);
}

Map the query endpoint next to the command mapping. Future engineers will thank you with beets and bears.

using MediatR;
app.MapGet("/quotes/{id}", async (Guid id, ISender sender)
=> Results.Ok(await sender.Send(new GetQuoteQuery(id))));

How this looks on disk

A common layout is Features per folder. Each folder holds everything the slice needs.

  • Features/
    • Quotes/
      • Create/
        • CreateQuoteCommand.cs
        • CreateQuoteHandler.cs
        • CreateQuoteValidator.cs
        • Endpoints.cs
      • Get/
        • GetQuoteQuery.cs
        • GetQuoteHandler.cs
        • Endpoints.cs

No mega Services folder. No scavenger hunts. Each slice reads like a short story with a clear beginning and end.

Why this scales

  • Fewer merge conflicts: small, isolated edits per feature.
  • Independent evolution: reads and writes can change without stepping on each other.
  • Easier testing: handlers are simple to unit test without ASP.NET hosting.

Here is a tiny test style snippet to show how handlers stay testable.

var handler = new CreateQuoteHandler();
var dto = await handler.Handle(new CreateQuoteCommand("Pam", 10, 1.5m), default);
Console.WriteLine(dto.Total); // 15.0

When vertical slices are not your best move

  • Very small apps with only a handful of endpoints can be fine with a simple controller setup.
  • Heavy domain models might favor a richer domain layer with aggregates and domain events.
  • Teams unfamiliar with CQRS may prefer to ease in by migrating one feature at a time.

Wrap up

Vertical Slice Architecture is a mindset that favors feature focus, clear boundaries, and friction free delivery. With Minimal APIs, MediatR, and FluentValidation, you can build slices that are easy to reason about and steady under change. Trade that folder safari for a tidy set of features and watch your throughput rise faster than Kevin can spill chili.