DEV Community

Yander Santiesteban Rojas
Yander Santiesteban Rojas

Posted on

The Hidden Runtime Cost of MediatR-Style Pipelines

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:

  1. 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.
  2. 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.

Runtime-composed mediator vs source-generated dispatch: on the left, each behavior wraps a delegate and closure and the handler is resolved per call; on the right, generated code makes direct typed calls through interface dispatch to a cached handler

What it looks like

dotnet add package DSoftStudio.Mediator
Enter fullscreen mode Exit fullscreen mode
public record Ping() : IRequest<int>;

public class PingHandler : IRequestHandler<Ping, int>
{
    public ValueTask<int> Handle(Ping request, CancellationToken ct)
        => new ValueTask<int>(42);
}
Enter fullscreen mode Exit fullscreen mode
services.AddMediator(builder =>
{
    builder.AddOpenBehavior(typeof(LoggingBehavior<,>));
    builder.AddRequestPreProcessor<AuditPreProcessor>();
});
Enter fullscreen mode Exit fullscreen mode
var result = await mediator.Send(new Ping()); // that's it
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 DerivedEvent never invokes INotificationHandler<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)
Enter fullscreen mode Exit fullscreen mode

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)