If you've shipped a CQRS codebase on a MediatR-style library, you know the trade-off. The mediator pattern buys you decoupling: thin controllers, one handler per use case, cross-cutting concerns as pipeline behaviors. But it charges two taxes in return:
- The runtime tax. Reflection-based dispatch, service resolution on the hot path, and allocations that grow with pipeline depth. At scale, that's GC pressure, and GC pressure shows up directly in your p99.
- The visibility tax. The pipeline is assembled at runtime inside the DI container, so nobody can actually see it: which behaviors wrap this command, where that notification fans out, which component is slow.
This post is about removing the first tax with DSoftStudio.Mediator, an MIT-licensed, source-generated mediator for .NET. The second tax gets its own post next in this series.
Where the runtime tax comes from
In a classic runtime-composed mediator, every Send walks the DI container: resolve the handler wrapper, resolve the behavior chain, and wrap each behavior around the next as a delegate with a closure. Three behaviors = three delegate allocations + closures, per request. It works, but you pay in allocations that scale with pipeline depth, and in a call path the runtime can't see through.
The fix isn't micro-optimizing that machinery. It's moving the work to compile time.
DSoftStudio.Mediator does this with a source generator that emits handler discovery and registration, the precompiled behavior chain per request type, closed notification dispatch tables, and C# 12 interceptors that turn Send/Publish/CreateStream call sites into direct typed invocations. By the time your app starts, there is no pipeline left to compose at runtime; what executes is ordinary generated C# you can read in your IDE.
What it looks like
dotnet add package DSoftStudio.Mediator
public record Ping() : IRequest<int>;
public class PingHandler : IRequestHandler<Ping, int>
{
public ValueTask<int> Handle(Ping request, CancellationToken ct)
=> new ValueTask<int>(42);
}
services.AddMediator(builder =>
{
builder.AddOpenBehavior(typeof(LoggingBehavior<,>));
builder.AddRequestPreProcessor<AuditPreProcessor>();
});
var result = await mediator.Send(new Ping()); // that's it
The API is deliberately MediatR-compatible (requests, notifications, streams, behaviors, pre/post processors, exception handlers), so migration is mostly mechanical (handlers return ValueTask instead of Task; there's a migration table in the README). You also get CQRS aliases: ICommand<T> / IQuery<T> with matching handler interfaces.
No magic under the hood
Everything above is ordinary C# emitted into your build: the DI registrations, the typed Send overloads, the dispatch tables. On the hot path there is no reflection, no MakeGenericType, no delegate chain: just a static flag check and a direct, typed Handle call, with a single cached resolve as the only DI touch. That is also why the library is Native AOT and trimming safe by construction: there is no dynamic code path to guard.
And if you ever want to audit exactly what runs, dotnet build -p:EmitCompilerGeneratedFiles=true drops the generated sources under obj/…/generated/. What you register is what runs.
The design detail that makes allocations constant
public interface IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
ValueTask<TResponse> Handle(
TRequest request,
IRequestHandler<TRequest, TResponse> next, // ← not a delegate
CancellationToken cancellationToken);
}
next is a typed handler, not a Func<>. Behaviors chain through interface dispatch: no closures, no per-behavior allocations. The result: 72 B per Send, whether you have 0, 3, or 5 behaviors. Delegate-wrapping mediators grow linearly with depth.
Two more compile-time perks worth knowing (the rest live in the README):
-
Exact-type notification dispatch. Publishing
DerivedEventnever invokesINotificationHandler<BaseEvent>. No more "why did this handler fire twice" archaeology. It's a deliberate semantic difference from MediatR; know it before migrating. - Build-time diagnostics. The analyzer flags a request with no handler, duplicate handlers, or a mocking library colliding with interceptors while you compile, not at 2 a.m. in production.
The numbers, and how to reproduce them
The question that matters isn't "how fast is Send in a vacuum". It's does the mediator add cost to your actual pipeline? So the headline benchmark is a realistic one: Validation → Logging → Metrics → a simulated async database write (a real async hop via Task.Yield), three behaviors, real DI, each library measured against its own direct-call baseline:
| Library | Pipeline latency | Allocated | vs its own direct call |
|---|---|---|---|
| DSoftStudio.Mediator | 667 ns | 255 B | 0.99× |
| DispatchR 2.1 | 667 ns | 255 B | 1.01× |
| Mediator (Source Gen) 3.0 | 718 ns | 397 B (1.5×) | 1.06× |
| MediatR 14.1 | 857 ns | 1,032 B (3.8×) | 1.20× |
Allocation multiples are each library's ratio vs its own direct-call baseline (~270 B).
In this pipeline, dispatching through DSoftStudio.Mediator is statistically indistinguishable from calling the code directly (0.99×). The measured cost is the handler's own work, not the dispatch layer.
The micro numbers explain why: pure Send is 7.2 ns / 72 B (direct call: 7.0 ns). With five behaviors: 15.6 ns, still 72 B, while MediatR reaches 153 ns / 1,088 B. Publish is 4.5 ns and zero-alloc. Constant allocation is the property that matters in production: fewer Gen0 collections → tighter p99/p999.
Environment: BenchmarkDotNet 0.15.8, .NET 10, Windows 11, i7-12700F, each library in an isolated process. And here's the only call to action in this post: clone the repo and run the benchmark suite on your own hardware; per-library run scripts are included. Distrust of vendor benchmarks is healthy; that's why they ship with the code.
The DSoftStudio.Mediator companion packages
The core stays lean; integrations are focused MIT packages:
| Package | What it does |
|---|---|
DSoftStudio.Mediator.Abstractions |
Contracts only (netstandard2.0), with no dependency on the mediator runtime or generators. |
DSoftStudio.Mediator.OpenTelemetry |
Tracing + metrics for Send, Publish, and Stream. |
DSoftStudio.Mediator.FluentValidation |
Automatic request validation as a pipeline behavior. |
DSoftStudio.Mediator.HybridCache |
L1 + L2 response caching via Microsoft's HybridCache. |
The Abstractions split keeps clean/hexagonal layering honest. The generator runs only in the composition root and discovers handlers across project references:
Host / API → DSoftStudio.Mediator (AddMediator + source generator)
Application → DSoftStudio.Mediator.Abstractions (handlers, requests, behaviors)
Domain → (no mediator dependency)
Infrastructure → (no mediator dependency)
Guides for the core and every companion package (installation, CQRS concepts, streaming, Native AOT notes, integrations) live at docs.dsoftstudio.com/mediator, and the project homepage is mediator.dsoftstudio.com.
When you should not use this
- It's not a message bus; for cross-process messaging use MassTransit, NServiceBus, or a broker.
- It's not an event sourcing framework.
- If your design depends on runtime flexibility (inheritance-based notification dispatch, dynamic handler discovery), the compile-time model trades that away by design; weigh that before migrating.
- And if you don't need the mediator pattern at all, a direct method call is still the fastest mediator ever written.
That covers the runtime tax. The second tax is one no benchmark can fix: you still can't see the pipeline. Which behaviors wrap each command, where notifications fan out, which component is the slow one, and on which request. Next post in this series: turning the pipeline into something you can navigate, graph, and profile without leaving your IDE.

Top comments (0)