Mert Özen Further With Every Line
Software Architecture Clean, Onion and Hexagonal Architecture — #06

The Application Layer and Use Cases: Handlers, Validation, and Transaction Boundaries Without MediatR

Mert Özen Nov 7, 2026 16 min 6 views
The Application Layer and Use Cases: Handlers, Validation, and Transaction Boundaries Without MediatR

In the Application layer each use case is a single-purpose handler class: input validation stays in Web, business rules stay in the Domain, expected failures come back as a Result, and a single SaveChanges draws the transaction boundary. Adding cross-cutting concerns with Scrutor decorators instead of MediatR, and MediatR's licensing after 13.0.0.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First

In Bookstore's layered version, before the move to Clean Architecture, the ordering flow was written like this. The repository method calls its own SaveChangesAsync when it finishes; the service first reserves and saves the stock, then gets the fee from the shipping company's API (Application Programming Interface) and writes the order. The code compiles, most tests pass, and it ran in production for months:

public sealed class LegacyBookRepository(LegacyDbContext db)
{
    public Task<Book?> FindAsync(int id, CancellationToken ct) => db.Books.SingleOrDefaultAsync(b => b.Id == id, ct);

    public async Task UpdateAsync(Book book, CancellationToken ct)
    {
        db.Books.Update(book);
        await db.SaveChangesAsync(ct); // every repository call runs its own SaveChanges
    }
}

public sealed class LegacyShippingClient(HttpClient http)
{
    public async Task<decimal> QuoteAsync(string postalCode, int weightGrams, CancellationToken ct)
    {
        using HttpResponseMessage response = await http.PostAsJsonAsync("v2/rates", new { postalCode, weightGrams }, ct);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<decimal>(ct);
    }
}

public sealed class LegacyOrderService(LegacyBookRepository books, LegacyShippingClient shipping, LegacyDbContext db)
{
    public async Task<int> PlaceAsync(string email, string postalCode, int bookId, int quantity, CancellationToken ct)
    {
        Book book = await books.FindAsync(bookId, ct) ?? throw new KeyNotFoundException($"Book {bookId} not found.");
        Order order = new(email, postalCode);
        if (!order.TryAddItem(book, quantity))
            throw new InvalidOperationException($"Only {book.Stock} copies of '{book.Title}' left.");
        await books.UpdateAsync(book, ct); // the stock is committed here

        order.ApplyShipping(await shipping.QuoteAsync(postalCode, quantity * 400, ct));
        db.Orders.Add(order);
        await db.SaveChangesAsync(ct);
        return order.Id;
    }
}

A customer orders one copy of "Dune", which has 3 in stock. At that very moment the shipping company's API isn't answering. PlaceAsync throws, the endpoint returns 500, the customer sees "order could not be placed" and closes the page. Here is the question: after this request, what is Dune's stock in the database, and how many order rows are there? Second question: if tomorrow you had to trigger the same flow from a console command or a nightly background job, which parts of this code would you have to drag along?

The Half-Finished Order

I ran the flow on SQLite, giving it a closed port as the shipping address, and then read the tables back with a fresh DbContext:

HttpRequestException: Connection refused (localhost:5307)
Stock of 'Dune': 2
Orders in database: 0

No order, but the stock went down. UpdateAsync committed the stock with its own SaveChangesAsync; when the shipping call blew up, there was nothing left to roll back. Every failed attempt "loses" a copy. This is not an Entity Framework Core (EF Core) bug, it is a design outcome: the answer to "where does the operation begin and end?" is spread across two places. The repository method doesn't know it is part of a business flow, and the service doesn't know when the commit happens.

The answer to the second question comes from the same place. Because this service lives behind a controller or an endpoint, the real owner of the flow is unclear: failures are thrown as different exception types and translated to status codes in the HTTP (Hypertext Transfer Protocol) layer, and part of the validation stayed in the controller. When you want to call it from a console command, you have to reinterpret the exceptions and rewrite the validation.

In post 05 we set up the four-project solution and wrote PlaceOrderHandler so that it doesn't make this mistake, with a single IUnitOfWork. This post focuses on the layer that handler sits in: Application. In his 2012 Clean Architecture article, Robert C. Martin calls this circle "Use Cases" and puts application-specific business rules there: code that directs the flow of data into and out of the entities, but should not have to change when either the UI (user interface) or the database changes. We will take the first version from post 05 a few steps further along that definition: a shared handler contract for every use case, two kinds of validation in two places, a Result that carries the kind of failure, one transaction boundary, and a decorator for cross-cutting concerns without MediatR.

Use Case = Single-Purpose Handler

First the contract. The Application project defines a small interface that every use case follows:

namespace Bookstore.Application;

// The contract every use case follows
public interface IUseCaseHandler<in TRequest, TResponse>
{
    Task<Result<TResponse>> HandleAsync(TRequest request, CancellationToken ct);
}

Then the place-order use case. The input, the output and the handler sit in the same file; whoever changes one has to look at the others anyway, so keeping them together makes the job easier. There are two differences from post 05: the handler now implements IUseCaseHandler, and the input type is called PlaceOrderCommand instead of PlaceOrderRequest:

using Bookstore.Domain.Catalog;
using Bookstore.Domain.Ordering;

namespace Bookstore.Application.Ordering;

public sealed record PlaceOrderCommand(string CustomerEmail, string PostalCode, int BookId, int Quantity);
public sealed record PlaceOrderResponse(int OrderId, decimal Subtotal, decimal ShippingFee, decimal Total);

public sealed class PlaceOrderHandler(
    IBookRepository books, IOrderRepository orders, IShippingRates rates, IUnitOfWork unitOfWork)
    : IUseCaseHandler<PlaceOrderCommand, PlaceOrderResponse>
{
    private const int GramsPerBook = 400;

    public async Task<Result<PlaceOrderResponse>> HandleAsync(PlaceOrderCommand command, CancellationToken ct)
    {
        Book? book = await books.FindAsync(command.BookId, ct);
        if (book is null)
            return new Error("book.not_found", $"Book {command.BookId} not found.", ErrorKind.NotFound);

        Order order = new(command.CustomerEmail, command.PostalCode);
        if (!order.TryAddItem(book, command.Quantity))
            return new Error("book.out_of_stock", $"Only {book.Stock} copies of '{book.Title}' left.", ErrorKind.Conflict);

        order.ApplyShipping(await rates.QuoteAsync(command.PostalCode, command.Quantity * GramsPerBook, ct));
        orders.Add(order);
        await unitOfWork.SaveChangesAsync(ct); // single SaveChanges: stock and order in the same transaction
        return new PlaceOrderResponse(order.Id, order.Subtotal, order.ShippingFee, order.Total);
    }
}

The rename is not cosmetic. PlaceOrderCommand is not an HTTP request; it is the user's intent in the application's language: which customer, how many copies of which book, to where. There are no JSON (JavaScript Object Notation) attributes, route information or validation attributes in it. The HTTP request moves to Web below, and its name goes with it. There is no price in the command either: we take the price from the book in the catalog, not from whatever the client says.

The four dependencies in the handler's constructor are the ports Application defined in post 05: IBookRepository, IOrderRepository, IShippingRates and IUnitOfWork. Their implementations live in Infrastructure. The handler directs the flow but does not know the rules: the stock check is in Book.TryReserve, the free shipping threshold in Order.ApplyShipping, both in the Domain. The handler only sets the order of steps: fetch the book, report it if missing, ask the domain to add the item, ask for the shipping fee, save the result. That is exactly the "orchestration" in Martin's description.

Wiring the Handler to an Endpoint

The Web project is the driving adapter that speaks HTTP. It takes the request, turns it into a command, calls the handler and turns the result into an HTTP response. We moved the endpoint that lived inside Program.cs in post 05 into its own file:

using System.ComponentModel.DataAnnotations;
using Bookstore.Application;
using Bookstore.Application.Ordering;

namespace Bookstore.Web.Ordering;

// The HTTP contract belongs to Web; Application only knows the command
public sealed record PlaceOrderRequest(
    [Required, EmailAddress] string CustomerEmail,
    [Required, RegularExpression("^[0-9]{5}$")] string PostalCode,
    [Range(1, int.MaxValue)] int BookId,
    [Range(1, 99)] int Quantity)
{
    public PlaceOrderCommand ToCommand() => new(CustomerEmail, PostalCode, BookId, Quantity);
}

public static class OrderEndpoints
{
    public static IEndpointRouteBuilder MapOrderEndpoints(this IEndpointRouteBuilder app)
    {
        app.MapPost("/orders", async (
            PlaceOrderRequest request,
            IUseCaseHandler<PlaceOrderCommand, PlaceOrderResponse> handler,
            CancellationToken ct) =>
        {
            Result<PlaceOrderResponse> result = await handler.HandleAsync(request.ToCommand(), ct);
            return result.IsSuccess
                ? Results.Created($"/orders/{result.Value!.OrderId}", result.Value)
                : result.Error!.ToProblem();
        });
        return app;
    }
}
using Bookstore.Application;
using Bookstore.Infrastructure;
using Bookstore.Web.Ordering;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddProblemDetails();
builder.Services.AddValidation();
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);

WebApplication app = builder.Build();
await app.Services.InitialiseDatabaseAsync();

app.MapOrderEndpoints();
app.Run();

There are two ways to give the handler to the endpoint. The first is what we did in post 05: register the handler by its concrete type and ask for it that way:

builder.Services.AddScoped<PlaceOrderHandler>();
app.MapPost("/orders", async (PlaceOrderRequest request, PlaceOrderHandler handler, CancellationToken ct) =>
{
    Result<PlaceOrderResponse> result = await handler.HandleAsync(request.ToCommand(), ct);
    return result.IsSuccess ? Results.Created($"/orders/{result.Value!.OrderId}", result.Value) : result.Error!.ToProblem();
});

This is the plain option: no extra interface, "Go to Definition" takes you straight to the handler, and registration is one line. The price is that you can't wrap anything around the handler. When you want to add logging, timing or retry to every use case at once, you have to touch each handler individually. The second option is to ask for it through IUseCaseHandler<TRequest, TResponse>, as above. A generic interface has a cost, but it is also what makes the decorator you will see below possible. With a single use case the first option is enough; moving to the second when you reach ten or twenty use cases is just a refactoring.

Validation: Two Places, Two Different Questions

"Where does validation go?" is really two questions. The first is "does this request make sense as a shape?": is the email valid, is the postal code five digits, is the quantity positive? That is part of the HTTP contract and lives in Web, on the request record. AddValidation(), which we covered in the ASP.NET Core Web API series post "What's New in .NET 10: AddValidation, OpenAPI 3.1 and Server-Sent Events in ASP.NET Core", checks the DataAnnotations attributes on the record that is an endpoint parameter before the handler runs:

POST /orders {"customerEmail":"not-an-email","postalCode":"6100","bookId":1,"quantity":0}
HTTP 400
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"CustomerEmail":["The CustomerEmail field is not a valid e-mail address."],"PostalCode":["The field PostalCode must match the regular expression '^[0-9]{5}$'."],"Quantity":["The field Quantity must be between 1 and 99."]}}

The handler was never called. One detail matters here: the validation metadata is produced by a source generator for the assembly in which AddValidation is called. Because we keep the request record in Web, the generator finds it from the endpoint signature. Had you put the same attributes on the command in Application, Microsoft's documentation says that class library would need its own package reference and its own AddValidation registration. This technical detail lines up with the right architectural decision: the shape of the input is Web's problem, not Application's.

The second question is "does this request follow the business rules?": is there enough stock? That is an invariant that must hold no matter who calls, and it has lived in the Domain since post 05:

namespace Bookstore.Domain.Catalog;

public sealed class Book(int id, string title, decimal price, int stock)
{
    public int Id { get; } = id;
    public string Title { get; } = title;
    public decimal Price { get; } = price;
    public int Stock { get; private set; } = stock;

    public bool TryReserve(int quantity)
    {
        if (quantity <= 0 || quantity > Stock) return false;
        Stock -= quantity;
        return true;
    }
}

Notice that TryReserve also rejects a zero or negative quantity, and so does the [Range(1, 99)] in Web. This is not duplication. The rule in Web says "zero or more than 99 copies is meaningless for this API, show the user a field-level error". The rule in the Domain says "no matter who calls, stock can't be changed by a negative number". When you create the order from a console command, a test or any other driving adapter, Web's validation isn't involved, but the Domain's always is. Writing command validators in Application with FluentValidation is also a common choice; we compared it in the Web API series post "Model Validation in ASP.NET Core: Data Annotations and FluentValidation". Even if you take that route, don't move the invariants out of the Domain.

Result or Exception?

Since post 05, the handler has returned Task<Result<PlaceOrderResponse>>, not Task<PlaceOrderResponse>. Result and Error are small types at the root of Application. In this post we added one field to Error: the kind of failure.

namespace Bookstore.Application;

public enum ErrorKind { Validation, NotFound, Conflict }

public sealed record Error(string Code, string Message, ErrorKind Kind);

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

    public T? Value { get; }
    public Error? Error { get; }
    public bool IsSuccess => Error is null;

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

The distinction is this: "book not found" and "not enough stock" are expected business outcomes. They will happen every day, and the caller needs an answer for them. Expressing them as exceptions makes the flow invisible: the signature of LegacyOrderService in the opening never mentions the possibility of failure, and the caller learns which exceptions to catch only by reading the code. Result writes the failure into the return type. .NET itself takes the same path for expected failures: int.TryParse, Dictionary.TryGetValue and the OperationStatus enum in System.Buffers return the outcome instead of throwing. TryReserve and TryAddItem in the Domain use the same Try pattern; since the Domain depends on nothing, it doesn't know Result and returns a bool, which the handler turns into an Error. We covered the pattern in detail in the Clean Code series post "Error Handling in Clean Code: Exceptions, the Try Pattern and the Result Pattern" and in the Design Patterns series post "Code-Level Patterns Beyond GoF: Null Object, Specification, Result and Pipeline"; here we look at its architectural side.

Exceptions don't disappear. Infrastructure failures like the HttpRequestException in the opening, meaning the shipping company or the database can't be reached, are not business outcomes; they go to the global handler from the ASP.NET Core Web API series post "Global Error Handling in ASP.NET Core: Exception Middleware and ProblemDetails" and become a 500.

Translating a Result into HTTP is Web's job. Error knows nothing about HTTP; it only carries the kind of failure (ErrorKind), and the status code choice sits in a single extension method in Web:

using Bookstore.Application;

namespace Bookstore.Web;

public static class ErrorMapping
{
    public static IResult ToProblem(this Error error) => Results.Problem(
        statusCode: error.Kind switch
        {
            ErrorKind.NotFound => StatusCodes.Status404NotFound,
            ErrorKind.Conflict => StatusCodes.Status409Conflict,
            _ => StatusCodes.Status422UnprocessableEntity
        },
        title: error.Code,
        detail: error.Message);
}
POST /orders {"customerEmail":"ada@example.com","postalCode":"06100","bookId":42,"quantity":1}
HTTP 404
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"book.not_found","status":404,"detail":"Book 42 not found."}
POST /orders {"customerEmail":"ada@example.com","postalCode":"06100","bookId":1,"quantity":2}
HTTP 409
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"book.out_of_stock","status":409,"detail":"Only 1 copies of 'Dune' left."}

(I removed the traceId field from the outputs for brevity.) The endpoint in post 05 turned every failure into a 409; now a missing book is a 404 and a stock conflict is a 409. Because AddProblemDetails() is called, Results.Problem produces the ProblemDetails format from RFC (Request for Comments) 9457 and fills the type field from the status code on its own. The difference between 400 and 422 is deliberate too: 400 means "the request is malformed", 422 means "the request was understood but hit a business rule". We'll use 422 in the next post, for postal codes the shipping company doesn't deliver to.

The Transaction Boundary: One SaveChanges = Unit of Work

The fix for the half-finished order in the opening is in the handler's last lines: the repositories only collect changes, the shipping fee is requested before the commit, and the handler commits once, at the very end. IUnitOfWork is defined in Application, and its implementation is the DbContext in Infrastructure:

public interface IUnitOfWork
{
    Task SaveChangesAsync(CancellationToken ct);
}
internal sealed class BookstoreDbContext(DbContextOptions<BookstoreDbContext> options)
    : DbContext(options), IUnitOfWork
{
    public DbSet<Book> Books => Set<Book>();
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder) =>
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(BookstoreDbContext).Assembly);

    Task IUnitOfWork.SaveChangesAsync(CancellationToken ct) => SaveChangesAsync(ct);
}

Unit of Work, by its name in Martin Fowler's PoEAA (Patterns of Enterprise Application Architecture, 2002), is the pattern that tracks the objects affected during a business transaction and writes their changes out in one go. EF Core's DbContext already does this; Microsoft's API documentation describes DbContext as a combination of the Unit of Work and Repository patterns. Our IUnitOfWork is a one-method window onto that behavior, seen from Application's language. To see what EF Core does in that single SaveChangesAsync call, I turned on the Microsoft.EntityFrameworkCore.Database.Command and Database.Transaction log categories:

dbug: Microsoft.EntityFrameworkCore.Database.Transaction[20209]
      Beginning transaction with isolation level 'Unspecified'.
dbug: Microsoft.EntityFrameworkCore.Database.Transaction[20200]
      Began transaction with isolation level 'Serializable'.
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[@p1='?' (DbType = Int32), @p0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      UPDATE "Books" SET "Stock" = @p0
      WHERE "Id" = @p1
      RETURNING 1;
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[@p0='?' (Size = 15), @p1='?' (Size = 5), @p2='?' (DbType = Decimal)], CommandType='Text', CommandTimeout='30']
      INSERT INTO "Orders" ("CustomerEmail", "PostalCode", "ShippingFee")
      VALUES (@p0, @p1, @p2)
      RETURNING "Id";
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[@p3='?' (DbType = Int32), @p4='?' (DbType = Int32), @p5='?' (DbType = Int32), @p6='?' (DbType = Decimal)], CommandType='Text', CommandTimeout='30']
      INSERT INTO "OrderItems" ("BookId", "OrderId", "Quantity", "UnitPrice")
      VALUES (@p3, @p4, @p5, @p6)
      RETURNING "Id";
dbug: Microsoft.EntityFrameworkCore.Database.Transaction[20210]
      Committing transaction.
dbug: Microsoft.EntityFrameworkCore.Database.Transaction[20202]
      Committed transaction.
dbug: Microsoft.EntityFrameworkCore.Database.Transaction[20204]
      Disposing transaction.

The stock update, the order and the order item go in a single transaction. Had the shipping call blown up, the handler would never have reached SaveChangesAsync; the stock lowered in memory would have been thrown away with the DbContext when the scope ended. We'll measure this in post 07 by shutting down the real shipping adapter.

The rule is "one SaveChanges per use case". If a use case has to write to an external service and then write to the database again, or has to publish an event reliably, a single transaction is not enough; that is where explicit transactions or a pattern like the outbox come in. Those belong to the upcoming CQRS (Command Query Responsibility Segregation) and Event Sourcing series.

Cross-Cutting Concerns with a Decorator

We want to log how long every use case takes and which error code it ended with. Writing that inside the handlers means copying the same five lines into each one. Instead we write a decorator that wraps the handler:

using System.Diagnostics;
using Microsoft.Extensions.Logging;

namespace Bookstore.Application.Behaviors;

public sealed class LoggingHandlerDecorator<TRequest, TResponse>(
    IUseCaseHandler<TRequest, TResponse> inner,
    ILogger<LoggingHandlerDecorator<TRequest, TResponse>> logger) : IUseCaseHandler<TRequest, TResponse>
{
    public async Task<Result<TResponse>> HandleAsync(TRequest request, CancellationToken ct)
    {
        long start = Stopwatch.GetTimestamp();
        Result<TResponse> result = await inner.HandleAsync(request, ct);
        TimeSpan elapsed = Stopwatch.GetElapsedTime(start);

        if (result.IsSuccess)
            logger.LogInformation("{UseCase} succeeded in {ElapsedMs:0} ms", typeof(TRequest).Name, elapsed.TotalMilliseconds);
        else
            logger.LogWarning("{UseCase} failed with {ErrorCode} in {ElapsedMs:0} ms", typeof(TRequest).Name, result.Error!.Code, elapsed.TotalMilliseconds);
        return result;
    }
}

The decorator implements the same interface and leaves the real work to the handler inside. The most familiar example of this pattern in .NET is the Stream family: BufferedStream and GZipStream take another Stream and add buffering or compression around it, while still looking like a Stream from the outside. It is the same idea we covered in the Design Patterns series post "Decorator Pattern: Adding Caching, Logging and Retry Without Touching the Code".

The built-in DI (dependency injection) container doesn't support decorator registration directly. Scrutor (version 7.0.0) does two jobs: it scans the assembly to find the handlers and wraps them all with Decorate. The one-line AddScoped<PlaceOrderHandler>() from post 05 becomes this:

using Bookstore.Application.Behaviors;
using Microsoft.Extensions.DependencyInjection;

namespace Bookstore.Application;

public static class DependencyInjection
{
    public static IServiceCollection AddApplication(this IServiceCollection services)
    {
        // find every concrete handler implementing IUseCaseHandler<,> (excluding the generic decorator itself)
        services.Scan(scan => scan
            .FromAssemblyOf<Error>()
            .AddClasses(c => c.AssignableTo(typeof(IUseCaseHandler<,>)).Where(t => !t.IsGenericTypeDefinition))
            .AsImplementedInterfaces()
            .WithScopedLifetime());

        // wrap every handler with the logging decorator
        services.Decorate(typeof(IUseCaseHandler<,>), typeof(LoggingHandlerDecorator<,>));
        return services;
    }
}

The logs of the three requests that reached the handler while the app ran (the first one a successful order; the request that returned 400 never reached the handler):

info: Bookstore.Application.Behaviors.LoggingHandlerDecorator[0]
      PlaceOrderCommand succeeded in 97 ms
warn: Bookstore.Application.Behaviors.LoggingHandlerDecorator[0]
      PlaceOrderCommand failed with book.out_of_stock in 14 ms
warn: Bookstore.Application.Behaviors.LoggingHandlerDecorator[0]
      PlaceOrderCommand failed with book.not_found in 0 ms

When you add a new use case, you think about neither the logging nor the registration; writing the class is enough. You can add a caching or retry decorator the same way. Order matters: Decorate calls wrap from the inside out, so the last one called ends up outermost.

Do You Really Need MediatR?

What we have built so far is the equivalent of what a MediatR project does with IRequestHandler and IPipelineBehavior. The main thing MediatR adds is a dispatcher: instead of knowing the handler, the endpoint says mediator.Send(command), and the library looks at the request type to find the right handler. As we saw in the Design Patterns series post "Mediator Pattern: Centralizing Object Conversations and 'Do I Really Need MediatR?'", most of the decoupling comes from making the handler a separate class, not from the dispatcher. Seeing IUseCaseHandler<PlaceOrderCommand, PlaceOrderResponse> in the endpoint's parameters tells both the compiler and the reader exactly which use case is being called.

Then there is the license. From 13.0.0 on, MediatR is commercially licensed: the code is offered under RPL (Reciprocal Public License) 1.5, or under a separate commercial agreement for those who don't want that. As of November 2026, when this post was written, the official site lists the Community edition terms as: companies with annual revenue under 5 million dollars, no more than 10 million dollars of outside investment, and not a government entity can use it for free. Versions 12.x and earlier stay under their old open source license (Apache 2.0). The popular templates have split as well: jasontaylordev/CleanArchitecture still uses MediatR 14, while ardalis/CleanArchitecture v11 moved to the source-generator-based Mediator library, citing the license change. Whether to choose MediatR as an architectural decision is discussed in post 03 of the upcoming Vertical Slice Architecture series. From Clean Architecture's point of view the answer is simple: you don't need a dispatcher to protect the Dependency Rule.

Common Pitfalls

The assembly scan mistaking the generic decorator for a handler. In my first attempt, the scan line only had AssignableTo(typeof(IUseCaseHandler<,>)), and since LoggingHandlerDecorator<,> also implements that interface, it got registered as an open generic handler:

services.Scan(scan => scan
    .FromAssemblyOf<Error>()
    .AddClasses(c => c.AssignableTo(typeof(IUseCaseHandler<,>)), publicOnly: true)
    .AsImplementedInterfaces()
    .WithScopedLifetime());
services.Decorate(typeof(IUseCaseHandler<,>), typeof(LoggingHandlerDecorator<,>));

I didn't notice, because the existing use case worked. When I tried to resolve a request type whose handler hadn't been written yet, the container tried to put the decorator inside the decorator:

InvalidOperationException: A circular dependency was detected for the service of type 'Bookstore.Application.IUseCaseHandler<string, int>'.
Bookstore.Application.IUseCaseHandler<string, int>(Bookstore.Application.Behaviors.LoggingHandlerDecorator<string, int>) -> Bookstore.Application.IUseCaseHandler<string, int>

The fix is the Where(t => !t.IsGenericTypeDefinition) filter above. Listing the registrations once and checking them reveals this kind of surprise without even needing a test.

Calling SaveChanges inside the repository. This is what caused the half-finished order in the opening. The repository collects changes; the use case decides when to commit.

Calling a handler from a handler. Injecting another use case's handler into PlaceOrderHandler and calling it nests two transaction boundaries and makes the decorators run twice. If there is a shared step, extract it into a domain service or an ordinary class inside Application.

Leaking HTTP into Application. A handler that returns IResult, HttpContext or ProblemDetails makes it impossible to call the use case from a console. The check is easy: if you see a Microsoft.AspNetCore.App framework reference in Application's .csproj, something has slipped. In post 08 we will automate this with an architecture test.

Mistaking every check for duplication. Input validation and an invariant checking the same boundary is not duplication; it is two answers to two different questions. Real duplication is a business rule, say the free shipping threshold, being calculated both in the Domain and in an endpoint or handler; then one day someone updates only one of them.

A Small Experiment

★☆☆ In the variant where PlaceOrderHandler is registered by its concrete type, do you still see the LoggingHandlerDecorator logs? Why?

★★☆ Extend PlaceOrderCommand to take several books. If one of the books is missing or out of stock, nothing should be saved. How would you test that with the fakes from post 05?

★★★ Add a TimingBudgetDecorator that logs a warning when a use case takes longer than 500 ms. Change the order of the Decorate calls and observe whether the time measured by the logging decorator includes this decorator's own time.

Application now defines its ports, but behind IShippingRates there is still the flat-rate placeholder from post 05. In the next post we move to Infrastructure: how the EF Core mapping is set up without touching the domain, where persistence ignorance ends, and how we wrap the shipping company's API in a driven adapter with a typed HttpClient.