# The controller was never the unit. The use case is.

> A fat ASP.NET controller refactored one complaint at a time: feature folders, one class per use case, errors as values, and rules that survive leaving HTTP.

- Author: Simon Wimmesberger
- Published: 2026-08-06
- Canonical: https://blog.wimmesberger.dev/posts/the-controller-was-never-the-unit/
- Tags: dotnet, aspnet-core, architecture

---
Somewhere in every ASP.NET codebase past its third birthday there is an `OrdersController`. Mine was 1,400 lines. Twelve actions, seven constructor dependencies, and a green build. Nobody planned it. Every action was added for a defensible reason, every pull request was reviewed, and the sum is a class nobody wants to open. If you came up on Spring instead, you know the same file under a different annotation.

The usual response is to import an architecture: pick a template with a ring diagram on the README, move everything, hope. This post refuses to do that. We start from the fat controller and make only the moves that a concrete, felt complaint justifies, one at a time. **Followed honestly, the chain of small refactors does not end with a tidier controller; it ends with a different unit of code entirely, and a folder tree that finally says what the application does.**

Every step compiles. Every shape we land on turns out to have a name in the literature, the oldest from 1992. And along the way we'll be clear about which steps your codebase has not earned yet, and about the ways real teams fumble each one.

## The controller nobody planned

Here is the starting point, abridged. One action shown in full, because it will be our tracer bullet for the whole post:

```csharp title="Controllers/OrdersController.cs" collapse={12-16}
[ApiController]
[Route("api/orders")]
public sealed class OrdersController(
    AppDbContext db,
    IPaymentGateway payments,
    IInventoryService inventory,
    IEmailSender email,
    IShippingQuoteClient shipping,
    IDiscountEngine discounts,
    ILogger<OrdersController> logger) : ControllerBase {
    [HttpPost]
    public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct) {
        // ~90 lines: check stock, price the cart, apply discounts,
        // authorize payment, write the order, send the confirmation mail
        throw new NotImplementedException();
    }

    [HttpPost("{id:guid}/cancel")]
    public async Task<IActionResult> Cancel(Guid id, CancelOrderRequest request, CancellationToken ct) {
        var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id, ct);
        if (order is null) return NotFound();
        if (!order.CanCancel) return Conflict("Order already shipped.");

        await payments.RefundAsync(order.PaymentId, order.Total, ct);
        order.Status = OrderStatus.Cancelled;
        await db.SaveChangesAsync(ct);
        await email.SendCancellationAsync(order.CustomerEmail, ct);
        return NoContent();
    }

    // + GetById, List, UpdateAddress, AddItem, RemoveItem,
    //   ApplyDiscount, Reorder, ExportInvoice ... ten more actions
}
```

Look at the numbers rather than the style. Seven constructor dependencies; twelve actions; most actions use two or three of the seven. `Cancel` touches `db`, `payments` and `email`. `Create` touches six. Nothing touches all seven. The constructor is a union of requirements that no single method has, which means it describes the class and lies about every method in it.

The class sits in a solution laid out the way the project template suggested:

```txt
src/
├── Controllers/     14 controllers
├── Services/        23 services
├── Models/          61 request/response classes
├── Validators/
└── Data/
```

Try to answer one question from that tree: what does this application sell? You can't. The tree answers a different question, "which web framework is this?", and it answers it loudly.

## Every pull request is already vertical

Folders exist to group things that change together. That is their one job, so the test of a folder layout is the shape of your diffs.

Here is the file list of a real, boring feature PR, "let customers give a cancellation reason":

```txt
modified:  Controllers/OrdersController.cs
modified:  Models/CancelOrderRequest.cs
modified:  Validators/CancelOrderRequestValidator.cs
added:     Data/Migrations/20260714_AddCancellationReason.cs
modified:  Data/OrderEntityConfiguration.cs
```

One small feature, four top-level folders. Now check your own history; this one-liner counts how often each top-level folder appears in recent commits:

```bash frame="terminal"
git log --name-only --format= -30 | cut -d/ -f1 | sort | uniq -c | sort -rn
```

In a layered codebase the counts come out nearly uniform, because every feature crosses every layer. The only PRs that stay inside one folder are renames and framework upgrades. **The changes were vertical all along; only the folders are horizontal.** The layout groups files by what they are made of, while every unit of actual work groups them by what they are for.

Push on the claim yourself: pick a change, then regroup the same fifteen files.

So make the folders agree with the diffs. Move files, change nothing else:

```txt
src/
├── Orders/
│   ├── OrdersController.cs
│   ├── CancelOrderRequest.cs
│   ├── CancelOrderRequestValidator.cs
│   └── ...
├── Catalog/
├── Payments/
├── Shipping/
└── Platform/        auth, logging config, Program.cs plumbing
```

This is *package by feature*, and Robert C. Martin gave the resulting effect a name in a [2011 essay](https://blog.cleancoder.com/uncle-bob/2011/09/30/Screaming-Architecture.html): a screaming architecture. The top level of a system should tell you about the system, not about the framework it happens to be written in. A health-care app should look like a health-care app; ours should look like a shop. The idea is older still: Ivar Jacobson was organizing designs around use cases in his 1992 book on use-case driven design, before most of today's web frameworks existed.

One caveat: some code really is shared plumbing, and it needs a home. Give it a deliberately boring `Platform/` folder and treat growth there as a smell. If `Platform/` is your biggest folder, you've recreated the layer layout with extra steps.

The move costs nothing and pays immediately: a cancellation PR is now `git diff --stat src/Orders`, and a new teammate reads the domain from `ls src`. But notice what the move did *not* fix. The biggest file in `Orders/` is still a 1,400-line controller. The folders stopped being horizontal; the class hasn't.

## Thin controllers make fat services

The standard prescription for a fat controller is "thin controllers, fat services": move the logic into an `OrderService`, keep the controller as glue. Since we're touching every action anyway, this is the natural moment to drop the controller ceremony and switch to minimal APIs, where each route is a lambda or a plain static method and parameters come straight from the route, body and DI container:

```csharp title="Orders/OrdersEndpoints.cs"
public static class OrdersEndpoints {
    public static void MapOrders(this IEndpointRouteBuilder app) {
        var orders = app.MapGroup("/api/orders");

        orders.MapPost("/", CreateOrder);
        orders.MapPost("/{id:guid}/cancel", CancelOrder);
        orders.MapGet("/{id:guid}", GetOrder);
        // ... nine more
    }

    private static async Task<IResult> CancelOrder(
        Guid id, CancelOrderRequest request, OrderService service, CancellationToken ct) {
        var cancelled = await service.CancelAsync(id, request.Reason, ct);
        return cancelled ? Results.NoContent() : Results.Conflict();
    }
}
```

This is a real improvement, and it's worth naming why. Dependencies moved from the class to the method: each endpoint declares exactly what it uses, so the signature stops lying. It is also a performance fix: where MVC activates a controller instance and walks its filter pipeline per call, a minimal endpoint is a compiled request delegate, which is why minimal APIs benchmark faster than controllers with identical handler bodies. And the methods are plain static functions, testable without `ControllerBase` in the picture.

Then you open the service the endpoints now call:

```csharp title="Orders/OrderService.cs" {1-8}
public sealed class OrderService(
    AppDbContext db,
    IPaymentGateway payments,
    IInventoryService inventory,
    IEmailSender email,
    IShippingQuoteClient shipping,
    IDiscountEngine discounts,
    ILogger<OrderService> logger) {
    public Task<Order> CreateAsync(...) { ... }   // uses db, payments, inventory, shipping, discounts, email
    public Task<bool> CancelAsync(...) { ... }    // uses db, payments, email
    public Task<Order?> GetAsync(...) { ... }     // uses db
    // ... nine more methods
}
```

Put that constructor next to the old controller's constructor. It is the same constructor. Seven dependencies, twelve methods, most using two or three. We did not fix the fat class; we relocated it and confiscated its HTTP attributes.

That's because the grouping rule survived the move. The controller grouped "everything about orders"; the service groups "everything about orders". A class named after a noun accumulates verbs indefinitely, since every new verb about that noun plausibly belongs there. Meanwhile the class has twelve unrelated reasons to change, and unit-testing `CancelAsync` means constructing (or mocking) seven dependencies to exercise three.

So ask the question the noun has been deflecting: which of these twelve methods actually belong together? Check any pair. `CancelAsync` and `GetAsync` share a `DbContext` and the substring "order". That's the entire relationship.

## One verb, one class

If the methods share nothing but a noun, stop forcing them to share a class. Promote each method to its own class and let the method's real dependencies become the constructor:

```csharp title="Orders/CancelOrder.cs"
public sealed class CancelOrder(AppDbContext db, IPaymentGateway payments, IEmailSender email) {
    public sealed record Receipt(Guid OrderId, decimal Refunded);

    public async Task<Receipt?> HandleAsync(Guid orderId, string reason, CancellationToken ct) {
        var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == orderId, ct);
        if (order is null || !order.CanCancel) return null;

        await payments.RefundAsync(order.PaymentId, order.Total, ct);
        order.Status = OrderStatus.Cancelled;
        order.CancellationReason = reason;
        await db.SaveChangesAsync(ct);
        await email.SendCancellationAsync(order.CustomerEmail, ct);

        return new Receipt(order.Id, order.Total);
    }
}
```

Three dependencies, because cancellation needs three; the container resolves those and nothing else, where resolving `OrderService` dragged in all seven on every request. Unused constructor dependencies were never free, just quiet. One public method, because the class *is* the method.

And the inputs are plain parameters, because an id and a reason do not need a wrapper type; resist that ceremony until something forces it. Something will, and it will be a better reason than habit. The response type did move in, though: `Receipt` is a nested record because it belongs to this use case and nothing else, the first bite out of the 61-file `Models/` folder.

The endpoint shrinks to a translation layer between HTTP's shapes and the use case's signature:

```csharp title="Orders/OrdersEndpoints.cs"
orders.MapPost("/{id:guid}/cancel", async (
    Guid id, CancelOrderRequest body, CancelOrder handler, CancellationToken ct) =>
        await handler.HandleAsync(id, body.Reason, ct) is not null
            ? Results.NoContent()
            : Results.Conflict());
```

Plus one registration line, `builder.Services.AddScoped<CancelOrder>()`. File that under "wiring tax"; we will come back to it.

And notice the casualty. The controller answered `NotFound()` for a missing order and `Conflict("Order already shipped.")` for a late one; the endpoint above answers 409 for both, because the handler hands it one `null` for two different facts. This is not sloppiness we could fix with more care. `NotFound()` and `Conflict()` are HTTP's words, `IActionResult` is a transport type, and the handler no longer speaks HTTP. It has no language for failure yet. Park that wound; it reopens as soon as the handler gets a second caller.

Do this twelve times and look at the folder:

```txt
src/Orders/
├── CreateOrder.cs
├── CancelOrder.cs
├── GetOrder.cs
├── UpdateAddress.cs
├── ApplyDiscount.cs
├── Reorder.cs
├── ExportInvoice.cs
├── ...
└── OrdersEndpoints.cs
```

`ls src/Orders` now reads like the feature list your product owner keeps in their tracker. The screaming got one level deeper: the folders say what the app does, and the files say what each feature does.

This shape has been discovered so many times that it collects names. The Gang of Four called the mechanical part the *Command pattern* in 1994: an operation reified as an object. Jacobson's use-case driven design gave each use case its own control object in 1992; Clean Architecture calls the same role an *interactor*; the CQRS crowd (command query responsibility segregation, the school that splits reads from writes) says *handler*, and that's the word that stuck in .NET once MediatR made the style mainstream (Java readers know it from Axon's `@CommandHandler`). Jimmy Bogard named the whole way of organizing an application [vertical slice architecture](https://www.jimmybogard.com/vertical-slice-architecture/) in 2018. A shape rediscovered again and again from 1992 to 2018, in every major ecosystem, exists because a real force keeps producing it.

Note what we did not need: a mediator library. The pattern is one class per use case. Dispatch is ordinary dependency injection of the concrete class, and a rename is a compile error, exactly as it should be.

## What services are actually for

At this point an experienced reader should push back: if class-per-verb is right, why did every framework era push services? Because real services solve a different problem, and the push-back is worth steelmanning.

Look at what `CancelOrder` injects. `IPaymentGateway` has two implementations: the real one over the provider's API, and a fake for integration tests. The day finance renegotiates fees, there will be a third. You cannot split `StripePaymentGateway` per use case; its methods are cohesive because they wrap one external system and must be swapped *as a unit*. That is what the interface is for. Substitution is the point.

The same holds for genuinely shared state: a client that holds an access token and refreshes it, a cache, a connection. State couples methods for real, not by naming convention. And a pure calculation used by several handlers (the discount engine) is a fine service too; it doesn't even need an interface until a second implementation shows up.

The distinction that falls out is the one this whole post orbits: **handlers are your application's verbs; services are the tools those verbs use.** A handler is something you would demo to your product owner. A service is something you would swap in a test. A handler has one public method and a name that reads like a sentence; a service is cohesive because of state or substitution, and its name is a capability. If a class has twelve public methods, each called from exactly one place, it is twelve classes in a trench coat.

Apply that lens to `OrderService` after the twelve promotions and the file is empty. There is nothing left to refactor because its methods were never one thing. The real services were the leaf dependencies it injected. `OrderService` doesn't get redesigned. It evaporates.

## The handler doesn't know HTTP exists

Scroll back to `CancelOrder.cs` and notice something we never explicitly decided: there is no `using Microsoft.AspNetCore.*` in it. It takes an order id and a reason, returns a `Receipt`. It is not web code. That was a side effect of chasing honest constructors, and it is the most valuable thing we've built so far, because cancellation was never exclusively an HTTP feature:

- The broker delivers a `PaymentFailed` message after the final retry: cancel the order.
- A nightly job expires unpaid orders: cancel each one.
- Support tooling and admin CLIs cancel orders with no browser in sight.
- An integration test wants to cancel an order without standing up a test server.
- And since this is 2026: an AI agent calling a `cancel_order` tool over the Model Context Protocol (MCP).

In the controller days, each of those either duplicated the logic or did something gruesome like invoking the controller from a fake request context. Now each one is three lines:

```csharp title="Orders/PaymentFailedConsumer.cs"
public sealed class PaymentFailedConsumer(CancelOrder cancelOrder) {
    public Task HandleAsync(PaymentFailed message, CancellationToken ct) =>
        cancelOrder.HandleAsync(message.OrderId, "payment failed", ct);
}
```

This, too, has a name. Alistair Cockburn described it in [2005](https://alistair.cockburn.us/hexagonal-architecture/) as the hexagonal, or ports-and-adapters, architecture. The word *port* deserves precision here, because the hexagon has them on both sides.

On the driving side, a port is an entry point the application offers to whatever wants to trigger it: the handler's method, with the endpoint, the consumer and the CLI as adapters plugged into it. On the driven side, a port is a need the application states as an interface: `IPaymentGateway` or `IEmailSender`, with the Stripe client and the SMTP sender as adapters plugged into those. Read that against the previous section: the services we just defended turn out to be the other half of the hexagon. Handlers are how the world drives the application; driven ports are how the application drives the world.

Cockburn's stated goal was an application "equally driven by users, programs, automated test or batch scripts". Swap "batch scripts" for "message consumers and AI agents" and it is a 2026 sentence.

We did not set out to build a hexagon. We fixed constructors, and transport independence fell out.

## Failure is domain data, not a status code

The new callers immediately reopen the wound we parked at the extraction. Look at the consumer again: it discards the handler's return value, and the compiler is fine with that. What would it do with the value anyway? `null` might mean the order never existed, which for a payment-failure message is routine (the order was purged; acknowledge and move on), or that the order can no longer be cancelled, which someone may need to hear about. Two opposite reactions, one indistinguishable value.

The controller knew the difference; `NotFound()` and `Conflict("Order already shipped.")` carried exactly this information. But they carried it in HTTP. A status code is one transport's dialect for failure, which is why the distinction could not move into the handler with the logic, and why it degraded to `null` on the way out.

Exceptions are the other familiar channel, and they are wrong for this job. An already-shipped order is not exceptional; for a cancellation flow it is a normal Tuesday, an outcome every caller must handle every time. Throwing makes expected outcomes invisible in the signature (`Task<Receipt>` promises nothing about a `CannotCancelException`), and it taxes every transport with a try/catch that has to know which exceptions count as expected. Keep exceptions for bugs.

Here is the twist: you already know the right answer, because the controller was using it. `return NotFound()` is not a throw. It is a failure returned as an ordinary value, and every ASP.NET developer has been comfortable with that for a decade. **The pattern was never the problem; its vocabulary was borrowed from the transport.** The handler needs the same move with the HTTP taken out, and a minimal version is under twenty lines:

```csharp title="Platform/Result.cs"
public enum ErrorKind { Validation, NotFound, Conflict, Unauthorized }

public sealed record Error(ErrorKind Kind, string Message) {
    public static Error NotFound(string message) => new(ErrorKind.NotFound, message);
    public static Error Conflict(string message) => new(ErrorKind.Conflict, message);
}

public sealed class Result<T> {
    private Result(T? value, Error? error) { Value = value; Error = error; }

    public T? Value { get; }
    public Error? Error { get; }

    public static implicit operator Result<T>(T value) => new(value, null);
    public static implicit operator Result<T>(Error error) => new(default, error);
}
```

The handler now states facts instead of shrugging:

```csharp title="Orders/CancelOrder.cs" del={1,5} ins={2,6-7}
public async Task<Receipt?> HandleAsync(Guid orderId, string reason, CancellationToken ct)
public async Task<Result<Receipt>> HandleAsync(Guid orderId, string reason, CancellationToken ct) {
    var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == orderId, ct);

    if (order is null || !order.CanCancel) return null;
    if (order is null) return Error.NotFound($"Order {orderId} does not exist.");
    if (!order.CanCancel) return Error.Conflict("Order already shipped.");

    // refund, mark cancelled, save, notify
    return new Receipt(order.Id, order.Total);   // implicit: T -> Result<T>
}
```

The endpoint's lossy ternary becomes `(await handler.HandleAsync(id, body.Reason, ct)).ToHttpResult()`, and the success case starts returning the `Receipt` rather than an empty 204, which it arguably should have all along. The whole HTTP dialect is written once per transport rather than once per action:

```csharp title="Platform/Http/ResultHttpExtensions.cs"
public static class ResultHttpExtensions {
    public static IResult ToHttpResult<T>(this Result<T> result) => result.Error switch {
        null                             => Results.Ok(result.Value),
        { Kind: ErrorKind.NotFound } e   => Results.NotFound(e.Message),
        { Kind: ErrorKind.Conflict } e   => Results.Conflict(e.Message),
        { Kind: ErrorKind.Validation } e => Results.BadRequest(e.Message),
        { Kind: ErrorKind.Unauthorized } => Results.Unauthorized(),
        { } e                            => Results.Problem(e.Message),
    };
}
```

And the consumer translates the same facts into queue manners instead of HTTP manners, growing an `ISupportNotifier` beside the handler for the conflict case:

```csharp title="Orders/PaymentFailedConsumer.cs"
var result = await cancelOrder.HandleAsync(message.OrderId, "payment failed", ct);

// the order was purged: this message is stale, acknowledge and move on
if (result.Error is { Kind: ErrorKind.NotFound }) return;

// already shipped: not a retry case, a human decision; hand it to support
if (result.Error is { Kind: ErrorKind.Conflict })
    await support.NotifyAsync(message.OrderId, result.Error.Message, ct);
```

Same facts, two translations, neither inside the handler. The 404-versus-409 distinction is back, stated once in the domain's terms instead of hand-coded per action. Notice the grain of the fix: per transport, not per use case. Hold onto that grain; it is about to carry the whole next act.

Prior art, as always. Rust ships this as `Result<T, E>` and F# as `Result<'T, 'TError>`; Scott Wlaschin's [railway oriented programming](https://fsharpforfunandprofit.com/rop/) talk (2014) is its best-known articulation, and Go compressed the idea into a slogan with Rob Pike's ["Errors are values"](https://go.dev/blog/errors-are-values) (2015). C# is getting union types of its own: a `union` keyword is [in preview for C# 15](https://devblogs.microsoft.com/dotnet/csharp-15-union-types/) as of .NET 11 Preview 2, and a success-or-error result is their textbook use case. Until that ships everywhere you deploy, the twenty-line class stands in; libraries exist too, but the shape matters, not the package.

The return type was the small casualty of leaving HTTP. Patching it exposes the big one.

## The rules stayed behind in the pipeline

ASP.NET's request pipeline is middleware: functions nested around every request, each seeing the request on the way in and the response on the way out. Over the years, real rules accumulated there and in its endpoint-level cousins (filters): request validation, authorization policies, a transaction per request, logging with correlation ids, idempotency for retried POSTs, exception-to-status-code mapping.

Every one of those was written as a rule about *an HTTP request*. But read them again as business statements: "cancellations must be authorized", "a cancellation is atomic", "cancellations are audited". Not one of them mentions HTTP. **They are rules about the use case that we happened to implement as rules about one transport.**

Which means our proud new consumer is a bug. When the queue delivers `PaymentFailed` and the consumer calls `CancelOrder` directly, no authorization runs, no transaction wraps the writes, no audit line lands, because all of that lives in a pipeline the message never passed through. Picture the incident: an order goes into dispute, support locks cancellation behind a permission, and the HTTP policy enforces the lock faithfully against every browser and script. Then a stale `PaymentFailed` message arrives for that order. The consumer never asks about permissions; the disputed order is cancelled and refunded mid-dispute, and the audit trail has nothing to say about who did it, because the audit filter was HTTP middleware too.

There are exactly three ways out, and two are traps. Reimplement the pipeline per transport, and you maintain an N-transports-by-M-concerns matrix where every forgotten cell is an incident. Inline the concerns into every handler body, and you have copy-paste with human memory as the enforcement mechanism. Or: wrap the handler itself, once, and let every transport call the wrapped thing. The grain from the error translation returns, pushed further: this time the rule is written exactly once, with nothing left to copy per transport.

Here is the incident, playable. Cancel the disputed order through both doors, then move the rules in with the handler and try again:

## Decorators are middleware that moved in with the logic

To wrap all handlers uniformly we finally need something they share, and right now they share nothing. Two gaps, specifically: no common interface, and no common shape, because every handler declares its own parameter list. The interface is the easy half. Note that we got this far without one; it earns its existence only now, for wrapping rather than dispatch:

```csharp title="Platform/IHandler.cs"
public interface IHandler<TRequest, TResponse> {
    Task<TResponse> HandleAsync(TRequest request, CancellationToken ct);
}
```

The shape is the interesting half. `TRequest` is one type parameter, and `CancelOrder.HandleAsync` takes two arguments. Nothing can be generic over "whatever parameters the method happens to declare", so each handler's inputs collapse into a nested record the class owns:

```csharp title="Orders/CancelOrder.cs" ins={2,3,6} collapse={7-16}
public sealed class CancelOrder(AppDbContext db, IPaymentGateway payments, IEmailSender email)
    : IHandler<CancelOrder.Command, Result<CancelOrder.Receipt>> {
    public sealed record Command(Guid OrderId, string Reason);
    public sealed record Receipt(Guid OrderId, decimal Refunded);

    public async Task<Result<Receipt>> HandleAsync(Command command, CancellationToken ct) {
        var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == command.OrderId, ct);
        if (order is null) return Error.NotFound($"Order {command.OrderId} does not exist.");
        if (!order.CanCancel) return Error.Conflict("Order already shipped.");

        await payments.RefundAsync(order.PaymentId, order.Total, ct);
        order.Status = OrderStatus.Cancelled;
        order.CancellationReason = command.Reason;
        await db.SaveChangesAsync(ct);
        await email.SendCancellationAsync(order.CustomerEmail, ct);

        return new Receipt(order.Id, order.Total);
    }
}
```

This is the ceremony we refused earlier, and the pipeline just paid for it. It keeps paying. `DataAnnotations` finally have somewhere to live: the validator walks an object's properties, and a parameter list is not an object, so until now a generic validation decorator had nothing to inspect. (One gotcha: on a positional record, write `[property: Required]`, or the attribute lands on the constructor parameter, where the validator never looks.) Every transport gets a serializable contract, since a queue message or an RPC payload deserializes straight into `Command`, where a parameter list has no wire shape. And `Models/` finishes dissolving: request and response types now both live inside the use case that owns them.

The literature named this object long ago, a *command* when it changes something and a *query* when it only reads. The names arrived here the way everything else in this post has: describing a shape the constraints already forced.

A *decorator* (Gang of Four, 1994, same book as Command) implements the same interface as the thing it holds, and adds behavior around the call. If that sounds familiar, it should: ASP.NET middleware is this exact pattern applied to the HTTP pipeline. Like the result type one section ago, nothing about the idea is new; only its address changes.

```csharp title="Platform/TransactionDecorator.cs"
public sealed class TransactionDecorator<TRequest, TResponse>(
    IHandler<TRequest, TResponse> inner,
    AppDbContext db) : IHandler<TRequest, TResponse> {
    public async Task<TResponse> HandleAsync(TRequest request, CancellationToken ct) {
        // disposing an uncommitted transaction rolls it back
        await using var tx = await db.Database.BeginTransactionAsync(ct);
        var response = await inner.HandleAsync(request, ct);
        await tx.CommitAsync(ct);
        return response;
    }
}
```

Write its siblings the same way: a `LoggingDecorator` that opens a scope and times the call, a `ValidationDecorator` that runs the `Command`'s `DataAnnotations` before anything else, an `AuthorizationDecorator` that checks the current principal against a requirement declared on the handler. Each is a middleware you already had, rewritten once, one level down, where every transport can benefit.

Composition is nesting, and for one handler it looks like this:

```csharp title="Program.cs (abridged)"
builder.Services.AddScoped<IHandler<CancelOrder.Command, Result<CancelOrder.Receipt>>>(sp =>
    new LoggingDecorator<CancelOrder.Command, Result<CancelOrder.Receipt>>(
        new TransactionDecorator<CancelOrder.Command, Result<CancelOrder.Receipt>>(
            sp.GetRequiredService<CancelOrder>(),
            sp.GetRequiredService<AppDbContext>()),
        sp.GetRequiredService<ILogger<CancelOrder>>()));
```

The container still constructs `CancelOrder` itself, through the registration line from earlier; the factory only stacks the rings around it. Notice the side effect: the same use case is now available in two forms, wrapped behind `IHandler<,>` and bare as `CancelOrder`, and we'll come back to what the naked one costs. Even trimmed, nobody should write this chain twelve times; the wiring tax just came due, and we'll settle it in the closing. The shape, though, is the destination:

MediatR calls these *pipeline behaviors*; Axon calls them handler interceptors; Spring people will recognize around-advice. Same decorator, different badge. And transport middleware does not disappear, it gets demoted to transport concerns: TLS, CORS, compression, rate limiting by IP. Rules about the pipe stay on the pipe. Rules about the use case wrap the use case.

## How this goes wrong on a real team

Run this refactor on a real codebase and the failures arrive in a predictable order, each one a way of performing the motions while keeping the disease.

The first happens in week one, out of caution. Deleting `OrderService` feels risky, so it survives, and twelve new handlers appear that each forward to one of its methods. Every call now makes an extra hop and nothing is decoupled; the seven-dependency constructor lives on, one layer deeper and harder to see. The tell is a handler whose entire body is one call to a method with the same name. A handler that does not own its logic is a costume, and deleting the service it hides is the point of the exercise, not a cleanup for later.

A few weeks in, someone adds a dispatch library, because registering handlers by hand has gotten old. That complaint is legitimate (the closing deals with it), but the library also makes it possible to send any request from anywhere, and convenience finds a way: handlers start sending to handlers. Six months later nobody can say which use case runs inside whose transaction, and "who calls this?" takes runtime tracing to answer. Keep dispatch boring. When the callee is known at compile time, inject it; when two handlers genuinely share logic, extract a domain service, which is the job we agreed services still have.

Then the decorator chain grows past three rings and the ordering bugs start. Validation runs inside the transaction, so garbage requests open database transactions. Authorization sits outside logging, so denied attempts never reach the audit trail. A retry ring lands outside the transaction ring and re-runs a refund whose commit already failed. Middleware ordering bugs were a classic for a reason, and moving the pipeline into your own code does not repeal them. The chain's order is a contract: write it down in exactly one place, and write the test that asserts it.

Somewhere along the way a background path skips the pipeline, usually with the words "just this once". We wrote that bug ourselves: the consumer injects the concrete `CancelOrder`, the naked registration the composition root still hands out, and on the queue path every decorator silently vanishes. The symptom is unmistakable (works over HTTP, corrupts over the queue), and the rule it earns is short: transports depend on `IHandler<,>` and resolve it from the container, and nothing constructs a handler by hand except its own tests.

The last failure is erosion rather than an event. `Result<T>` starts absorbing exceptions: a handler body gets wrapped in a blanket try/catch that converts every crash into an error value, and genuine bugs (a null reference, a bad connection string) travel on as polite data with their stack traces gone. In the opposite lane, somebody tired of the ceremony throws `NotFoundException` because it is shorter, and an expected outcome becomes invisible control flow again. Hold the line where the derivation drew it: the `Result` carries what the use case expects, exceptions carry bugs and infrastructure failures, and neither borrows the other's channel.

## Every piece has exactly one home

The whole post compresses into a routing table:

| You are writing | It is | Put it in |
| --- | --- | --- |
| Logic for one request/response use case | a verb | its own handler class |
| An expected failure of a use case (not found, already shipped) | a domain fact | an error value in the `Result`, translated per transport |
| A capability with a real second implementation | a tool | a service behind an interface |
| State shared across calls (token, cache, connection) | a tool | a service |
| Domain logic several handlers reuse | a tool | a plain service, interface optional |
| A rule about one transport (CORS, rate limits, TLS) | pipe plumbing | middleware or endpoint filter |
| A rule about every use case (validation, authorization, transactions, audit) | use-case plumbing | a decorator on `IHandler<,>` |

## An architecture you never decided to adopt

Count the moves. We aligned folders with diffs, moved injection from constructors to the methods that meant it, promoted each verb to a class, demoted services to tools, taught failure to speak the domain's language instead of HTTP's, and relocated cross-cutting rules from the transport to the use case. Notice how little we invented: the request pipeline already knew the decorator pattern, `IActionResult` already treated failure as a value, an MVC action was already a use-case method. Every piece existed at the transport layer; the refactors moved each one down to the layer every transport can share.

The result answers to several published names at once: screaming architecture at the folder level, command/handler at the class level, vertical slices as the organizing principle, errors as values in the signatures, ports and adapters at the boundary. The names are corroboration. Seven authors between 1992 and 2018 hit the same shape because the forces are real; the architecture is just the residue of taking specific complaints seriously.

Two honest costs before you swing the axe. First, files: a class per verb means many small files, and a six-endpoint internal tool does not have this problem and should keep its controller; this walk is for codebases where the controller has birthdays. Second, wiring: registrations and decorator chains grow linearly with handlers, and that `Program.cs` block from earlier will not write itself twelve times. You buy it back with assembly scanning and a library, or you move the wiring to compile time; I've written about that trade separately, in [Source generators as an application architecture](../source-generators-as-an-architecture/).

## Where I stopped deriving and started building

Every debt this post parked is something I eventually got tired of paying by hand. [Elarion](https://github.com/swimmesberger/Elarion), my open-source .NET application framework, is what came out of paying them once, properly: the shape of this post with the tedium compiled away. I'll keep this short, because the argument doesn't need a product; read it as evidence that the derivation doesn't stop here.

The wiring tax is the obvious debt. A handler is a class with an attribute, and source generators emit the registration, the decorator rings around it, and the endpoint mapping at compile time; nobody writes the `Program.cs` chain from earlier, the build does. The emitted registration is the wrapped one, so a consumer injecting `IHandler<,>` gets the same rings as HTTP without anyone having to remember the rule.

The transport matrix collapses the same way. One handler declares its exposures with attributes and is projected as a REST endpoint, a JSON-RPC method and an MCP tool for AI agents, three adapters over the same use case. The error translation this post wrote as `ToHttpResult` exists once per transport inside the framework, over a `Result<T>` and error kinds that are recognizably the twenty-line version from this post, grown up. Validation and authorization are declared on the handler and enforced identically on every transport, which is the middleware argument made mandatory.

The feature folders became modules that can be switched off in configuration, and the cross-feature coupling the handlers-calling-handlers failure warned about is watched by an analyzer instead of a reviewer: reaching into another module's internals is a build diagnostic, not a regret. The parts this post never reached follow the same rule every section here followed: state the intent on the use case, let the machinery live one layer down.

None of this is an argument to adopt a framework, mine or anyone's; it is what the end of the thread looks like when one person keeps pulling. Your codebase can stop at feature folders, or at handlers, or at decorators, and be better at every stop. **The unit of a web application is the use case: one verb, one class, its rules wrapped around it, and every transport kept thin enough to delete.** The next time a fat controller bites you, don't install a template (not even mine). Fix the one complaint in front of you, then the next. The shape assembles itself.
