Slices with a Mediator: Comparing MediatR, Source-Generated Mediator, and Wolverine
The real gain of a mediator in slices is a pipeline that is independent of the entry point; as of November 2026 MediatR 14.3 is RPL 1.5 or commercially licensed, while Mediator 3.0.2 and Wolverine 6.62 are MIT. We write the same PlaceOrder slice with all three tools and with a mediator-free endpoint filter, and compare dispatch cost, first-request time, Native AOT behaviour, and each tool's real setup pitfalls.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
After MediatR's licence change, the team decides to move to the source-generated Mediator library. The slice's handler is the same as in the previous post: it takes BookstoreDbContext through its constructor. The registration is as short as in the README:
builder.Services.AddMediator(options =>
{
options.PipelineBehaviors = [typeof(LoggingBehavior<,>)];
});
According to the library's documentation, handlers default to a singleton lifetime; DbContext is scoped. Guess: if you start the application first in Development, then in Production, and send 40 order requests at the same time, what happens? Is it the same in both environments?
What a Mediator Gains, and What It Costs
In the previous post we built the slice without any library: the endpoint injected the handler class and called it directly. A mediator puts a dispatcher in between: the endpoint doesn't know the handler, it hands the request object to the mediator, and the mediator looks at the request's type and finds the right handler. The difference between this dispatcher and the GoF (Gang of Four: the 1994 Design Patterns book by Gamma, Helm, Johnson, and Vlissides) Mediator pattern, and the general answer to "do I really need MediatR?", are in the Design Patterns series post "Mediator Pattern: Centralizing Object Conversations and 'Do I Really Need MediatR?'". This post looks at a narrower question: what does a mediator practically bring to vertical slices, and how do you choose between three popular tools?
The gains fall under three headings. First, a uniform handler contract: every slice's handler implements the same interface, and a newcomer learns the structure from one slice. Second, the pipeline: logging, validation, and transactions are wrapped around every handler in one place. Third, and least discussed, independence from the entry point: the same pipeline runs whether the request comes from an HTTP (Hypertext Transfer Protocol) endpoint, a background job, or a message consumer.
The cost is indirection. "Go to Definition" in the IDE (integrated development environment) on sender.Send(command) takes you to ISender.Send, not to the handler; to find the handler you search for IRequestHandler<PlaceOrderCommand, ...>. With most tools a missing handler is a runtime error, not a compile error. VSA (Vertical Slice Architecture) softens that cost somewhat: the command, handler, and endpoint sit in the same folder, often in the same file; what you're looking for is one screen down.
We'll try three tools on the same slice. The shared code (entities, BookstoreDbContext, and a BuildOrderAsync extension method that loads prices and builds the order) lives in a class library; each variant writes only the slice and the pipeline. The pipeline has one cross-cutting concern: logging how long each request took.
A Slice with MediatR 14.3
using Bookstore.Common;
using MediatR;
namespace Bookstore.Features.Orders;
public sealed record PlaceOrderCommand(int CustomerId, List<PlaceOrderLine> Lines) : IRequest<PlaceOrderResponse?>;
public sealed class PlaceOrderHandler(BookstoreDbContext db) : IRequestHandler<PlaceOrderCommand, PlaceOrderResponse?>
{
public async Task<PlaceOrderResponse?> Handle(PlaceOrderCommand command, CancellationToken ct)
{
Order? order = await db.BuildOrderAsync(command.CustomerId, command.Lines, ct);
if (order is null)
return null;
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return new PlaceOrderResponse(order.Id, order.Items.Sum(i => i.UnitPrice * i.Quantity));
}
}
public static class PlaceOrderEndpoint
{
public static void Map(IEndpointRouteBuilder app) =>
app.MapPost("/orders", async (PlaceOrderCommand command, ISender sender, CancellationToken ct) =>
await sender.Send(command, ct) is { } response
? Results.Created($"/orders/{response.OrderId}", response)
: Results.ValidationProblem(new Dictionary<string, string[]> { ["Lines"] = ["Unknown book"] }));
}
using System.Diagnostics;
using MediatR;
namespace Bookstore.Pipeline;
// A cross-cutting concern that runs in one place for every request
public sealed class LoggingBehavior<TRequest, TResponse>(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
: IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
long start = Stopwatch.GetTimestamp();
TResponse response = await next(ct);
logger.LogInformation("{Request} handled in {Elapsed:F1} ms", typeof(TRequest).Name, Stopwatch.GetElapsedTime(start).TotalMilliseconds);
return response;
}
}
using Bookstore.Common;
using Bookstore.Features.Orders;
using Bookstore.Pipeline;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddBookstoreDb("mediatr.db");
builder.Services.AddMediatR(cfg =>
{
cfg.LicenseKey = builder.Configuration["MediatR:LicenseKey"];
cfg.RegisterServicesFromAssemblyContaining<PlaceOrderHandler>();
cfg.AddOpenBehavior(typeof(LoggingBehavior<,>));
});
WebApplication app = builder.Build();
app.Services.ResetDatabase();
PlaceOrderEndpoint.Map(app);
app.Run();
The log after three orders and one unknown book:
warn: LuckyPennySoftware.MediatR.License[0]
You do not have a valid license key for the Lucky Penny software MediatR. This is allowed for development and testing scenarios. If you are running in production you are required to have a licensed version. Please visit https://luckypennysoftware.com to obtain a valid license.
info: Bookstore.Pipeline.LoggingBehavior[0]
PlaceOrderCommand handled in 96.5 ms
info: Bookstore.Pipeline.LoggingBehavior[0]
PlaceOrderCommand handled in 13.7 ms
info: Bookstore.Pipeline.LoggingBehavior[0]
PlaceOrderCommand handled in 1.3 ms
info: Bookstore.Pipeline.LoggingBehavior[0]
PlaceOrderCommand handled in 1.9 ms
The first line is the licence warning, written once at startup because LicenseKey is empty. The licensing details are below. One detail differs from older samples: in 14.3, RequestHandlerDelegate<TResponse> takes a CancellationToken, so the behavior calls next(ct) instead of a parameterless next().
A Slice with Mediator (Source Generator)
martinothamar/Mediator offers an API (Application Programming Interface) very similar to MediatR's, using code generated at build time instead of reflection. The slice is almost identical; the differences are in the return type and in the behavior's delegate:
using Bookstore.Common;
using Mediator;
namespace Bookstore.Features.Orders;
public sealed record PlaceOrderCommand(int CustomerId, List<PlaceOrderLine> Lines) : ICommand<PlaceOrderResponse?>;
public sealed class PlaceOrderHandler(BookstoreDbContext db) : ICommandHandler<PlaceOrderCommand, PlaceOrderResponse?>
{
public async ValueTask<PlaceOrderResponse?> Handle(PlaceOrderCommand command, CancellationToken ct)
{
Order? order = await db.BuildOrderAsync(command.CustomerId, command.Lines, ct);
if (order is null)
return null;
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return new PlaceOrderResponse(order.Id, order.Items.Sum(i => i.UnitPrice * i.Quantity));
}
}
using System.Diagnostics;
using Mediator;
namespace Bookstore.Pipeline;
// A cross-cutting concern that runs in one place for every message
public sealed class LoggingBehavior<TMessage, TResponse>(ILogger<LoggingBehavior<TMessage, TResponse>> logger)
: IPipelineBehavior<TMessage, TResponse> where TMessage : notnull, IMessage
{
public async ValueTask<TResponse> Handle(TMessage message, MessageHandlerDelegate<TMessage, TResponse> next, CancellationToken ct)
{
long start = Stopwatch.GetTimestamp();
TResponse response = await next(message, ct);
logger.LogInformation("{Message} handled in {Elapsed:F1} ms", typeof(TMessage).Name, Stopwatch.GetElapsedTime(start).TotalMilliseconds);
return response;
}
}
Now to the opening question. In Development the application didn't start at all:
Unhandled exception. System.AggregateException: Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: Bookstore.Features.Orders.PlaceOrderHandler Lifetime: Singleton ImplementationType: Bookstore.Features.Orders.PlaceOrderHandler': Cannot consume scoped service 'Bookstore.Common.BookstoreDbContext' from singleton 'Bookstore.Features.Orders.PlaceOrderHandler'.) ...
ASP.NET Core keeps scope validation on in the Development environment and rejects, at startup, a singleton handler capturing a scoped DbContext. In Production that validation is off by default. I started the same code in Production and sent 40 concurrent requests:
1 201
39 500
System.InvalidOperationException: A second operation was started on this context instance before a previous operation completed. This is usually caused by different threads concurrently using the same instance of DbContext. For more information on how to avoid threading issues with DbContext, see https://go.microsoft.com/fwlink/?linkid=2097913.
The singleton handler holds a single DbContext for the lifetime of the application and every request shares it. Because each request tried on its own works, this bug can stay hidden until there is load. It's the captive dependency from the ASP.NET Core Web API series post "Dependency Injection in ASP.NET Core: Lifetimes and the Captive Dependency Trap", arriving through a mediator. The fix is one line:
using Bookstore.Common;
using Bookstore.Features.Orders;
using Bookstore.Pipeline;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddBookstoreDb("mediator.db");
builder.Services.AddMediator(options =>
{
// Default is Singleton; handlers use DbContext, so they must be Scoped
options.ServiceLifetime = ServiceLifetime.Scoped;
options.PipelineBehaviors = [typeof(LoggingBehavior<,>)];
});
WebApplication app = builder.Build();
app.Services.ResetDatabase();
PlaceOrderEndpoint.Map(app);
app.Run();
Another benefit of the source generator is that it reports a missing handler at build time. When I wrote a CancelOrder command without a handler, Mediator warned during the build, while MediatR failed only at run time:
Program.cs(10,22): warning MSG0005: MediatorGenerator found message without any registered handler: CancelOrder
Unhandled exception. System.InvalidOperationException: No service for type 'MediatR.IRequestHandler`2[CancelOrder,System.String]' has been registered.
A Slice with Wolverine
Wolverine is more than a mediator: at heart it's a messaging framework, and mediator usage is a subset of it. Its approach differs too: handlers don't implement an interface, they're found by convention.
using Bookstore.Common;
using Wolverine;
namespace Bookstore.Features.Orders;
public sealed record PlaceOrderCommand(int CustomerId, List<PlaceOrderLine> Lines);
// No interface: Wolverine finds the class by its name and the method by the name Handle
public static class PlaceOrderHandler
{
public static async Task<PlaceOrderResponse?> Handle(PlaceOrderCommand command, BookstoreDbContext db, CancellationToken ct)
{
Order? order = await db.BuildOrderAsync(command.CustomerId, command.Lines, ct);
if (order is null)
return null;
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return new PlaceOrderResponse(order.Id, order.Items.Sum(i => i.UnitPrice * i.Quantity));
}
}
public static class PlaceOrderEndpoint
{
public static void Map(IEndpointRouteBuilder app) =>
app.MapPost("/orders", async (PlaceOrderCommand command, IMessageBus bus, CancellationToken ct) =>
await bus.InvokeAsync<PlaceOrderResponse?>(command, ct) is { } response
? Results.Created($"/orders/{response.OrderId}", response)
: Results.ValidationProblem(new Dictionary<string, string[]> { ["Lines"] = ["Unknown book"] }));
}
using System.Diagnostics;
using Wolverine;
namespace Bookstore.Pipeline;
// Convention-based middleware: Before runs before the handler, Finally after it
public static class LoggingMiddleware
{
public static long Before() => Stopwatch.GetTimestamp();
public static void Finally(long start, Envelope envelope, ILogger logger) =>
logger.LogInformation("{Message} handled in {Elapsed:F1} ms", envelope.MessageType, Stopwatch.GetElapsedTime(start).TotalMilliseconds);
}
using Bookstore.Common;
using Bookstore.Features.Orders;
using Bookstore.Pipeline;
using Wolverine;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Wolverine's generated code expects DbContextOptions to be a singleton
builder.Services.AddBookstoreDb("wolverine.db", optionsLifetime: ServiceLifetime.Singleton);
builder.Host.UseWolverine(opts =>
{
// In-process mediator only: no queues, transports or durable messages
opts.Durability.Mode = DurabilityMode.MediatorOnly;
opts.Policies.AddMiddleware(typeof(LoggingMiddleware));
});
WebApplication app = builder.Build();
app.Services.ResetDatabase();
PlaceOrderEndpoint.Map(app);
app.Run();
I didn't get here on the first try. With only the WolverineFx package, the application didn't start:
System.InvalidOperationException: Wolverine is running in TypeLoadMode.Dynamic, which compiles handler/middleware code at runtime, but no IAssemblyGenerator (Roslyn) is registered. Core WolverineFx no longer ships the runtime compiler. Either add the 'WolverineFx.RuntimeCompilation' NuGet package (it auto-registers when referenced, or call opts.UseRuntimeCompilation() in UseWolverine(...)), or pre-generate code with 'dotnet run -- codegen write' and set opts.CodeGeneration.TypeLoadMode = TypeLoadMode.Static. See https://wolverinefx.net/guide/codegen.html (GH-2876).
Wolverine generates an adapter class for every handler; since 6.0, the Roslyn compiler that builds this code at run time lives in a separate package. With WolverineFx.RuntimeCompilation added the application started, but the first request came back with this:
Wolverine.Configuration.InvalidServiceLocationException: Found service locations while generating code for Message Handler for Bookstore.Features.Orders.PlaceOrderCommand, but ServiceLocationPolicy.NotAllowed is in effect (it is the default).
...
The service registration for Microsoft.EntityFrameworkCore.DbContextOptions<Bookstore.Common.BookstoreDbContext> is an 'opaque' lambda factory with the Scoped lifetime and requires service location
What the generated code looks like explains the error. The dotnet run -- codegen write command writes it to disk; here's part of the adapter for our handler:
public override async System.Threading.Tasks.Task HandleAsync(Wolverine.Runtime.MessageContext context, System.Threading.CancellationToken cancellation)
{
await using var bookstoreDbContext = new Bookstore.Common.BookstoreDbContext(_dbContextOptionsOfBookstoreDbContext);
// The actual message body
var placeOrderCommand = (Bookstore.Features.Orders.PlaceOrderCommand)context.Envelope.Message;
var result_of_Before = Bookstore.Pipeline.LoggingMiddleware.Before();
try
{
Wolverine.Runtime.WolverineTracing.MessageActivity(context.Envelope)?.SetTag("message.handler", "Bookstore.Features.Orders.PlaceOrderHandler");
Wolverine.Runtime.WolverineTracing.MessageActivity(context.Envelope)?.SetTag("handler.type", "Bookstore.Features.Orders.PlaceOrderHandler");
// The actual message execution
var outgoing1 = await Bookstore.Features.Orders.PlaceOrderHandler.Handle(placeOrderCommand, bookstoreDbContext, cancellation).ConfigureAwait(false);
// Outgoing, cascaded message
await context.EnqueueCascadingAsync(outgoing1).ConfigureAwait(false);
}
finally
{
Bookstore.Pipeline.LoggingMiddleware.Finally(result_of_Before, context.Envelope, ((Microsoft.Extensions.Logging.ILogger)_loggerForMessage));
}
}
Wolverine doesn't ask the container for DbContext; it news it up from the options itself, and for the options to be passed to the constructor once, they must be a singleton. That's what the optionsLifetime parameter of AddDbContext is for. The middleware isn't a decorator either; it's inlined as lines in the generated code: no reflection, no delegate chain. The price is compiling that code on first use, as the measurements below show. There's also a small but surprising rule: when I wrote a handler returning int for the benchmark, Wolverine didn't treat it as a handler. DescribeHandlerMatch explained "EXCLUDED -- Returns a primitive type"; return values are treated as cascading messages, so primitive types are left out.
A Pipeline Without a Mediator: Endpoint Filters
You can write the same logging without any library, using ASP.NET Core's endpoint filters. The handler is a plain class as in the previous post; the filter is added to the group all slices hang under:
using System.Diagnostics;
namespace Bookstore.Pipeline;
// No mediator: the same cross-cutting concern in an endpoint filter
public sealed class LoggingFilter(ILogger<LoggingFilter> logger) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
long start = Stopwatch.GetTimestamp();
object? result = await next(context);
logger.LogInformation("{Endpoint} handled in {Elapsed:F1} ms", context.HttpContext.GetEndpoint()?.DisplayName, Stopwatch.GetElapsedTime(start).TotalMilliseconds);
return result;
}
}
WebApplication app = builder.Build();
app.Services.ResetDatabase();
RouteGroupBuilder slices = app.MapGroup("").AddEndpointFilter<LoggingFilter>();
PlaceOrderEndpoint.Map(slices);
app.Run();
info: Bookstore.Pipeline.LoggingFilter[0]
HTTP: POST /orders handled in 99.0 ms
info: Bookstore.Pipeline.LoggingFilter[0]
HTTP: POST /orders handled in 13.4 ms
Same result, different boundary: the filter only sees requests that come over HTTP. Call the same handler from a background job and the filter doesn't run. If there's a single entry point (true for most APIs) that's not a gap; if there are several, you need either a mediator's pipeline or a decorator around the handler. We built the decorator route, with Scrutor and without MediatR, in the Clean, Onion, and Hexagonal Architecture series post "The Application Layer and Use Cases: Handlers, Validation, and Transaction Boundaries Without MediatR"; the filter types are covered in detail in the ASP.NET Core Web API series post "Filters in ASP.NET Core Web API: Action Filters, Result Filters and ServiceFilter".
Measurements: Dispatch Cost and the First Request
Performance comparisons of mediators usually look at dispatch cost. With BenchmarkDotNet 0.15.8, without a database, I called an empty handler that adds one to a number and wraps it in a record, four ways (a Mac with an Apple M5 (10 cores), .NET 10.0.13):
| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
|---------- |-----------:|----------:|----------:|------:|--------:|-------:|----------:|------------:|
| Direct | 10.242 ns | 1.7477 ns | 1.6348 ns | 1.02 | 0.22 | 0.0115 | 96 B | 1.00 |
| MediatR | 35.878 ns | 0.5117 ns | 0.4536 ns | 3.59 | 0.55 | 0.0296 | 248 B | 2.58 |
| Mediator | 7.641 ns | 0.0685 ns | 0.0641 ns | 0.76 | 0.12 | 0.0057 | 48 B | 0.50 |
| Wolverine | 197.557 ns | 0.4873 ns | 0.3804 ns | 19.75 | 3.01 | 0.0248 | 208 B | 2.17 |
Mediator appearing faster than even the direct call is a measurement artefact: Direct returns Task<T>, Mediator returns ValueTask<T>; the difference comes from the return type, not the dispatcher. The real lesson is in the magnitude: even the slowest is 0.2 microseconds. In the logs above an order request takes 1–14 milliseconds; dispatch cost is less than a thousandth of that. When choosing a mediator for slices, this table is almost never decisive.
The more visible difference is in the first request. I restarted each variant and sent the same order three times (curl's time_total, in seconds):
WithFilter 0.106 0.015 0.002
WithMediatR 0.111 0.014 0.003
WithMediator 0.110 0.015 0.002
WithWolverine 0.550 0.015 0.002
WithWolverineStatic 0.116 0.014 0.002
The 0.1 seconds on the first request is common to all (EF Core building its model, JIT (just-in-time) compilation); in Wolverine's Dynamic mode, compiling the handler code with Roslyn comes on top. Generating the code ahead of time with codegen write and switching to TypeLoadMode.Static removes the difference; Wolverine's documentation recommends that for production too.
Licensing (November 2026)
This section is time-sensitive; it was checked on November 8, 2026 against the packages on NuGet themselves, their licence files, and the projects' official pages.
MediatR: 13.0.0 (July 2, 2025) and later are distributed under a dual licence, Lucky Penny Software's commercial licence or the RPL (Reciprocal Public License) 1.5; the current version is 14.3.0. According to the LICENSE.md in the package, if you use it under RPL 1.5 you must also release the source of the software you build with it under the RPL terms; if you don't want that, you take the commercial licence. On the commercial side there is a free Community edition for organizations with under 5 million dollars in annual revenue, that have never received more than 10 million dollars in outside capital, and that are not government agencies. Paid tiers are priced by number of developers. A licence key is required either way; without one, nothing is blocked and the warning above is logged. 12.5.0 (April 1, 2025) is the last Apache 2.0 release and stays under that licence.
Mediator (martinothamar): the current stable version is 3.0.2, and the licence file in the package is MIT (the permissive open-source licence named after the Massachusetts Institute of Technology). 3.1 is at the release-candidate stage. Wolverine (WolverineFx): the current version is 6.62.0, and its licence expression on NuGet is MIT. We'll look at a different approach, FastEndpoints, in post 06.
Comparison
| Criterion | No mediator (handler + filter) | MediatR 14.3.0 | Mediator 3.0.2 | Wolverine 6.62.0 |
|---|---|---|---|---|
| Licence | None (framework) | RPL 1.5 or commercial; Community edition free under conditions | MIT | MIT |
| Dispatch (on this machine) | 10 ns, 96 B | 36 ns, 248 B | 8 ns, 48 B | 198 ns, 208 B |
| First request | 0.106 s | 0.111 s | 0.110 s | Dynamic 0.550 s, Static 0.116 s |
| Native AOT (small console test) | Not tested; adds no library | Published with IL2104 (trim) and IL3053 (AOT analysis) warnings, InvalidOperationException at startup | Published without warnings and ran | Static codegen is a prerequisite per the docs; not tested |
| Missing handler | Compile error if the class is missing, runtime error if the registration is (post 02) | Runtime error | Build warning MSG0005 (generator warning), runtime error | Runtime error |
| Pipeline | Endpoint filter (HTTP only) or decorator | IPipelineBehavior | IPipelineBehavior | Convention-based middleware, inlined into generated code |
| Learning curve | Lowest | Low, the most examples | Low, very close to MediatR | Higher: conventions, codegen, messaging concepts |
For the Native AOT row I published the same handler and behavior with both libraries in a console application with PublishAot enabled. MediatR needs types the trimmer can't see because of assembly scanning and open generic registrations; it may be possible to make it work with extra trimming configuration, but out of the box it didn't run. Mediator's generated code wires everything at compile time, so there was no problem.
This series' default is the mediator-free slice from post 02. When there are several entry points that must share the same pipeline, my first choice is Mediator: MIT licensed, the fastest, AOT-compatible, and an easy move from MediatR. If messaging is already on the agenda (outbox, queues, sagas), it's better to evaluate Wolverine not only as a mediator but as part of that platform; we'll come back to this in the upcoming Messaging and Event-Driven Architecture series. Staying on MediatR is a reasonable decision for teams with a large MediatR codebase that qualify for the Community edition or are ready to buy the licence.
Common Pitfalls
Leaving the lifetime at its default. As we saw above, Mediator defaults to singleton and scope validation is off in Production; a captive DbContext only blows up under load. Whichever library you choose, match the handlers' lifetime to DbContext's and run your integration tests with Development settings.
Using the pipeline in place of HTTP. Doing authorization, input validation, or producing HTTP responses in the mediator pipeline means filtering the slice twice. ASP.NET Core already provides validation (AddValidation), authorization, and exception handling; keep the mediator pipeline for work that must be independent of the entry point (transactions, auditing, publishing domain events).
Wiring slices to each other through the mediator. Calling sender.Send(new RestockBooks(...)) from inside the CancelOrder handler looks "loosely coupled" because there's a mediator, but it ties two slices to the same transaction and the same error flow. We'll return to this in detail in post 04.
Choosing by benchmark. The difference in nanoseconds is real, but invisible next to a database call. Licensing, AOT needs, the tools the team knows, and messaging plans should drive the choice.
A Small Experiment
★☆☆ In the Mediator variant, delete the ServiceLifetime line and start the application with ASPNETCORE_ENVIRONMENT=Production. Send requests one by one, then several concurrently; note when the error appears.
★★☆ Add a transaction behavior next to the logging behavior: it runs the handler inside an IDbContextTransaction and rolls back on an exception. Try writing the same thing as an endpoint filter and watch what happens when it's called from a background job.
★★★ Switch the Wolverine variant to TypeLoadMode.Static, generate the code with codegen write, and measure the first-request time. Then add a new parameter to LoggingMiddleware and run it without regenerating the code; what error do you get?
Choosing a mediator doesn't change what's inside a slice; the hard question starts when slices multiply. In the next post we talk about sharing across slices: what should be common, what is better copied, and when business rules move from handlers into the domain.