MediatR went commercial on July 2, 2025. If you have been running CQRS in ASP.NET Core for the last few years, your dispatcher just turned into a budget line item. In this article, I will build a custom CQRS dispatcher in .NET 10 that replaces MediatR with about 100 lines of code, supports the same pipeline behavior pattern, returns ValueTask<T> for fewer allocations, and benchmarks 4.4x faster than MediatR 12.4.1 on real BenchmarkDotNet runs. Let’s get into it.
Quick verdict. You do not need MediatR for CQRS. You also do not need to ship a 30-line reflection toy that ends up slower than MediatR. The right answer in .NET 10 is a FrozenDictionary<Type, RequestHandlerWrapper> dispatcher that builds typed wrappers once at startup and looks them up in O(1) at dispatch time. I benchmarked four approaches in this article and the FrozenDictionary version is 4.4x faster than MediatR 12.4.1, allocates 8.3x less memory per call, and works with Native AOT. The full runnable code, including BenchmarkDotNet results, lives in the GitHub repo.
If you are new to CQRS itself, start with my complete guide to the pattern first. This article assumes you already know what commands, queries, and pipeline behaviors are.
The MediatR Decision
On July 2, 2025, Jimmy Bogard launched the commercial editions of MediatR and AutoMapper under his new company, Lucky Penny Software, following the licensing update he announced earlier that spring. Versions 12 and earlier remain under their original open-source licenses (MediatR started on MIT and moved to Apache 2.0 for 12.5.0). Versions 13 and onward ship under a dual license, with a commercial tier required for many professional uses and a free community tier for individuals and non-commercial use. For individual developers and small teams, the impact varies by tier, but for a lot of enterprise teams the question changed overnight from “which version of MediatR are we on” to “do we pay, freeze, or replace.”
The honest answer is that all three are valid. Pay if MediatR is load-bearing in a system you would not touch with a barge pole. Freeze on 12.x if you can live without future features. Replace if you want a dispatcher you control entirely, that is faster than MediatR, that works with Native AOT, and that takes about 100 lines of code to build. This article is about the third option.
One point worth repeating: CQRS is not MediatR. CQRS is the architectural decision to separate reads from writes. MediatR is one in-process dispatcher implementation. You can do CQRS with raw handlers, with FastEndpoints, with Wolverine, with Brighter, or with the dispatcher I am about to build. The library is not the pattern.
Do You Even Need a Mediator for CQRS?
Strictly speaking, no. You can write CQRS handlers as plain classes and inject them directly into your endpoints:
app.MapPost("/products", async ( CreateProductCommand cmd, CreateProductCommandHandler handler, CancellationToken ct) =>{ var id = await handler.Handle(cmd, ct); return Results.Created($"/products/{id}", new { id });});That works. It is the lowest-overhead, zero-magic, zero-dependency approach. If your handlers do not need cross-cutting concerns like logging, validation, caching, or transaction wrapping, raw handlers are the right answer and you can stop reading here.
A dispatcher earns its keep when you want to add behaviors once and have them apply to every handler without touching each endpoint. The pipeline behavior pattern, the thing MediatR popularized, is the actual reason most teams reach for a mediator. A logging behavior wraps every command. A validation behavior fails fast on bad input. A caching behavior short-circuits queries. A transaction behavior wraps writes in an EF Core transaction. You write each behavior once and it composes around every handler automatically.
The dispatcher exists to build that composition. That is its only job. The interface shape, the lifetime, the registration mechanics - everything else is mechanism. Once you understand that, you stop thinking of MediatR as “the CQRS library” and start thinking of it as “one way to compose pipeline behaviors.” There are many ways to do that, and most of them are faster than MediatR.
The In-Memory vs Distributed Question (the part nobody answers)
This is the question I keep seeing in Stack Overflow comments and Reddit threads: “I want to build my own CQRS dispatcher, but my app runs on multiple instances behind a load balancer. Do I need a distributed mediator?”
No. And the reason matters because it kills the most common reason teams overcomplicate this.
An in-process dispatcher like MediatR or the one in this article runs per-request, per-instance. When an HTTP request lands on instance 3 of your API, instance 3’s dispatcher resolves the handler from instance 3’s DI container and runs the pipeline on instance 3’s CPU. The other instances are uninvolved. The load balancer is what distributes work across instances, not the dispatcher. Multi-instance scaling does not require a distributed mediator any more than if/else requires a distributed if.
The confusion comes from conflating two different concerns:
| Concern | Solved by |
|---|---|
| Sending a command or query to a handler in the same process | In-process dispatcher (MediatR, our custom one, raw handlers) |
| Fanning a domain event out to handlers on other instances | Distributed message bus (Wolverine, MassTransit, Brighter, NServiceBus) |
| Reliable delivery and outbox guarantees across processes | Bus + transactional outbox |
| Long-running workflows that survive process restarts | Sagas |
CQRS is the first row. You scale CQRS the same way you scale any stateless HTTP handler: add instances, let the load balancer split traffic, share a database. The dispatcher does not know or care that there are other instances. There is no shared state to coordinate.
If you also need cross-instance fan-out, that is a separate decision and a separate library. You can absolutely run a custom in-process dispatcher in front of Wolverine or MassTransit for the messaging side, or use Wolverine as both. But do not confuse “I need to handle 10k req/s” with “I need a distributed mediator.” The first is solved by horizontal scaling. The second is a different problem.
This is the short version of the rule:
If your domain events only need to reach handlers in the same process for the same request, an in-process dispatcher is enough. If they need to reach handlers in other processes, on other instances, after the request is over, you need a real bus.
Most CRUD APIs are the first case. Most “I need to send an email after this command commits” is also the first case if you do not mind the email being part of the request lifecycle. The moment you want the email to be sent reliably even if the API instance crashes mid-request, you have crossed into the second case and you need an outbox.
I will return to this in the Notifications section once the dispatcher is built.
The Dispatcher Benchmark
Before writing a single interface, I benchmarked four dispatcher implementations on .NET 10 with BenchmarkDotNet 0.15.4 to figure out what the right answer actually is:
- Raw method call. Direct invocation of
handler.Handle(request, ct). The baseline. - Reflection dispatcher. The naive build-your-own that almost every blog ships:
MakeGenericType+GetMethod+Invoke. This is what the top SERP results all use. - FrozenDictionary dispatcher. The approach I am going to walk through in this article.
- MediatR 12.4.1. The last MIT version, what most teams are running today.
The workload is a trivial Ping(string Message) -> string request whose handler returns request.Message synchronously, so the dispatcher overhead dominates the measurement. Hardware: Intel Core Ultra 9 275HX, .NET 10.0.5, x64 RyuJIT, Concurrent Server GC.
| Dispatcher | Mean | Ratio | Allocated |
|---|---|---|---|
| Raw method call (baseline) | 0.054 ns | 1.00 | 0 B |
| FrozenDictionary dispatcher | 11.476 ns | 214.51 | 24 B |
| MediatR 12.4.1 | 50.411 ns | 942.27 | 200 B |
| Reflection dispatcher | 148.535 ns | 2,776.37 | 288 B |
A few facts jump out of this table:
- FrozenDictionary is 4.4x faster than MediatR 12.4.1 (11.5 ns vs 50.4 ns) and allocates 8.3x less memory per call (24 B vs 200 B).
- The naive reflection dispatcher is 2.9x slower than MediatR (148.5 ns vs 50.4 ns). This is the punchline: most “build your own” tutorials produce a dispatcher that is slower than the thing they were trying to escape.
- FrozenDictionary is 12.9x faster than the reflection approach (11.5 ns vs 148.5 ns). The difference is everything: no per-call
MakeGenericType, noGetMethod, noMethodInfo.Invoke, no boxed argument array. - The 24 B allocation in the FrozenDictionary path is the per-call DI resolution of the transient handler. Register the handler as a singleton and the dispatch path itself drops to zero allocations.
Reproduction is one command:
cd CqrsCustom.Benchmarksdotnet run -c ReleaseThe full results, including margin of error and standard deviation, live in BENCHMARKS.md. Now let me show you how the FrozenDictionary version actually works.
Designing the Public API (MediatR-Compatible Shapes)
The interfaces are intentionally shape-compatible with MediatR 12. If your project already runs MediatR, you can replace one using statement and recompile. No handler bodies change.
public interface IRequest<out TResponse>;
public interface IRequest : IRequest<Unit>;
public readonly struct Unit{ public static readonly Unit Value = default;}IRequest<TResponse> is a marker for any request that returns a response. Commands and queries both implement it. Unit is the void-equivalent for commands that have no return value. These are deliberate matches for MediatR’s shapes.
public interface IRequestHandler<in TRequest, TResponse> where TRequest : IRequest<TResponse>{ ValueTask<TResponse> Handle(TRequest request, CancellationToken cancellationToken);}The one MediatR-incompatible decision: I return ValueTask<TResponse> instead of Task<TResponse>. ValueTask<T> avoids the heap allocation when a handler completes synchronously, which is a measurable win for cached queries and trivial commands. The cost is that callers cannot await a ValueTask more than once, which is rarely a problem in practice. The .NET docs lay out the full tradeoffs.
public delegate ValueTask<TResponse> RequestHandlerDelegate<TResponse>();
public interface IPipelineBehavior<in TRequest, TResponse> where TRequest : IRequest<TResponse>{ ValueTask<TResponse> Handle( TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken);}A behavior receives the typed request, a next delegate that invokes the next link in the chain, and a cancellation token. Calling next() runs whatever comes after in the pipeline; not calling next() short-circuits and skips the rest of the chain.
public interface ISender{ ValueTask<TResponse> Send<TResponse>( IRequest<TResponse> request, CancellationToken cancellationToken = default);}ISender is what your endpoints inject. Same name as MediatR. Same signature shape. Migrating from MediatR is a using-statement swap.
Building the FrozenDictionary Dispatcher
The core idea is simple. At application startup, scan the assembly for every IRequestHandler<TRequest, TResponse> implementation. For each one, build a strongly-typed RequestHandlerWrapper<TRequest, TResponse> instance. Store all the wrappers in a FrozenDictionary<Type, RequestHandlerBase> keyed by the concrete request type. At dispatch time, look the wrapper up and call into it. No per-call reflection. No per-call MakeGenericType.
The wrapper hierarchy is a two-level inheritance trick that lets a single dictionary hold instances of every (TRequest, TResponse) pair without losing the type information:
internal abstract class RequestHandlerBase;
internal abstract class RequestHandlerBase<TResponse> : RequestHandlerBase{ public abstract ValueTask<TResponse> Handle( IRequest<TResponse> request, IServiceProvider provider, CancellationToken cancellationToken);}
internal sealed class RequestHandlerWrapper<TRequest, TResponse> : RequestHandlerBase<TResponse> where TRequest : IRequest<TResponse>{ public override ValueTask<TResponse> Handle( IRequest<TResponse> request, IServiceProvider provider, CancellationToken cancellationToken) { var typed = (TRequest)request; var handler = provider.GetRequiredService<IRequestHandler<TRequest, TResponse>>(); var behaviors = provider.GetServices<IPipelineBehavior<TRequest, TResponse>>();
// Build the pipeline: handler at the core, behaviors wrapped outside in registration order. // Iterating in reverse means the first registered behavior runs outermost. RequestHandlerDelegate<TResponse> pipeline = () => handler.Handle(typed, cancellationToken);
foreach (var behavior in behaviors.Reverse()) { var next = pipeline; var current = behavior; pipeline = () => current.Handle(typed, next, cancellationToken); }
return pipeline(); }}Isn’t that cool? The recursive lambda composition is the trick that makes pipeline behaviors work. I start with a delegate that just invokes the handler. For each behavior, I wrap the current pipeline with a new lambda that calls the behavior’s Handle method, passing the previous pipeline as next. After the loop, pipeline is a chain of nested closures that runs the first registered behavior outermost and the handler innermost.
The dispatcher itself is twenty lines:
internal sealed class Dispatcher( IServiceProvider provider, DispatcherRegistry registry) : ISender, IPublisher{ public ValueTask<TResponse> Send<TResponse>( IRequest<TResponse> request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request);
if (!registry.RequestWrappers.TryGetValue(request.GetType(), out var wrapper)) { throw new InvalidOperationException( $"No handler registered for request type '{request.GetType().FullName}'."); }
// Reference-type cast - cheap, no boxing. return ((RequestHandlerBase<TResponse>)wrapper).Handle(request, provider, cancellationToken); }
// ... Publish for INotification omitted, see the repo}
internal sealed class DispatcherRegistry( FrozenDictionary<Type, RequestHandlerBase> requestWrappers, FrozenDictionary<Type, NotificationHandlerBase> notificationWrappers){ public FrozenDictionary<Type, RequestHandlerBase> RequestWrappers { get; } = requestWrappers; public FrozenDictionary<Type, NotificationHandlerBase> NotificationWrappers { get; } = notificationWrappers;}The dispatch path is one dictionary lookup, one reference cast, one virtual call. That is the entire reason the benchmark numbers are what they are. There is no MakeGenericType per call. No GetMethod. No Invoke with a boxed object[]. The wrapper instance was built once, at startup, when types were already known.
FrozenDictionary shipped in .NET 8 and is purpose-built for read-heavy lookup tables that are frozen after construction. It is optimized for the exact pattern we need here: fixed key set, hot read path, no mutations.
DI Registration in One Extension Method
Registration scans an assembly once at startup, builds the wrappers, and stores them in the singleton registry:
public static IServiceCollection AddDispatcher(this IServiceCollection services, Assembly assembly){ var requestWrappers = new Dictionary<Type, RequestHandlerBase>(); var notificationWrappers = new Dictionary<Type, NotificationHandlerBase>();
foreach (var type in assembly.GetTypes()) { if (type.IsAbstract || type.IsInterface) continue;
foreach (var iface in type.GetInterfaces()) { if (!iface.IsGenericType) continue; var def = iface.GetGenericTypeDefinition();
if (def == typeof(IRequestHandler<,>)) { services.AddScoped(iface, type);
var args = iface.GetGenericArguments(); var requestType = args[0]; var responseType = args[1];
if (!requestWrappers.ContainsKey(requestType)) { var wrapperType = typeof(RequestHandlerWrapper<,>) .MakeGenericType(requestType, responseType); requestWrappers[requestType] = (RequestHandlerBase)Activator.CreateInstance(wrapperType)!; } } // ... INotificationHandler branch omitted, see repo } }
var registry = new DispatcherRegistry( requestWrappers.ToFrozenDictionary(), notificationWrappers.ToFrozenDictionary());
services.AddSingleton(registry); services.AddScoped<Dispatcher>(); services.AddScoped<ISender>(sp => sp.GetRequiredService<Dispatcher>()); services.AddScoped<IPublisher>(sp => sp.GetRequiredService<Dispatcher>());
return services;}
public static IServiceCollection AddPipelineBehavior( this IServiceCollection services, Type openGenericBehaviorType){ services.AddScoped(typeof(IPipelineBehavior<,>), openGenericBehaviorType); return services;}The MakeGenericType and Activator.CreateInstance calls happen once per handler type at startup. The hot dispatch path never touches reflection again. This is the crucial design decision: pay the reflection cost once, when types are known, and store the result in a frozen lookup table.
In Program.cs, registration is two blocks:
builder.Services.AddDispatcher(Assembly.GetExecutingAssembly());
builder.Services.AddPipelineBehavior(typeof(LoggingBehavior<,>));builder.Services.AddPipelineBehavior(typeof(ValidationBehavior<,>));builder.Services.AddPipelineBehavior(typeof(CachingBehavior<,>));builder.Services.AddPipelineBehavior(typeof(TransactionBehavior<,>));Registration order is execution order. LoggingBehavior is the outermost wrap, so it sees the request first and the response last. TransactionBehavior is the innermost wrap, so it is the last thing before the handler.
Pipeline Behaviors That Actually Work
This is where competitor articles fall down. Most of them ship a single hardcoded “ValidatingDispatcher” decorator and call it pipeline behavior. Real pipeline behaviors are cross-cutting, composable, and open-generic so they apply to every handler without you wiring them up per command. Here are four production-ready behaviors I ship in the sample repo.
LoggingBehavior
public sealed class LoggingBehavior<TRequest, TResponse>(ILogger<LoggingBehavior<TRequest, TResponse>> logger) : IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>{ public async ValueTask<TResponse> Handle( TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken) { var requestName = typeof(TRequest).Name; logger.LogInformation("Handling {RequestName}", requestName);
var sw = Stopwatch.StartNew(); try { var response = await next(); sw.Stop(); logger.LogInformation("Handled {RequestName} in {Elapsed}ms", requestName, sw.ElapsedMilliseconds); return response; } catch (Exception ex) { sw.Stop(); logger.LogError(ex, "Handler {RequestName} threw after {Elapsed}ms", requestName, sw.ElapsedMilliseconds); throw; } }}The behavior captures the request name from generics, times the handler, and emits structured logs on both happy and sad paths. ILogger<LoggingBehavior<TRequest, TResponse>> gives you per-request-type log categories so you can filter by command in your log sink.
ValidationBehavior with FluentValidation
public sealed class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators) : IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>{ public async ValueTask<TResponse> Handle( TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken) { if (!validators.Any()) { return await next(); }
var context = new ValidationContext<TRequest>(request); var failures = new List<FluentValidation.Results.ValidationFailure>();
foreach (var validator in validators) { var result = await validator.ValidateAsync(context, cancellationToken); if (!result.IsValid) { failures.AddRange(result.Errors); } }
if (failures.Count > 0) { throw new ValidationException(failures); }
return await next(); }}The behavior runs every registered validator for the current request type, aggregates failures, and throws ValidationException if any rule fails. Combine with a global exception handler that converts ValidationException into RFC 9457 ProblemDetails and you have automatic 400-with-errors responses for every command.
CachingBehavior with HybridCache
For queries that should be cached, mark them with an opt-in interface:
public interface ICacheable{ string CacheKey { get; } TimeSpan? Expiration => null;}
public sealed record GetProductQuery(Guid Id) : IRequest<ProductDto?>, ICacheable{ public string CacheKey => $"product:{Id}"; public TimeSpan? Expiration => TimeSpan.FromMinutes(5);}The behavior wraps the handler with a HybridCache lookup:
public sealed class CachingBehavior<TRequest, TResponse>( HybridCache cache, ILogger<CachingBehavior<TRequest, TResponse>> logger) : IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>{ public async ValueTask<TResponse> Handle( TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken) { if (request is not ICacheable cacheable) { return await next(); }
var options = cacheable.Expiration is { } expiration ? new HybridCacheEntryOptions { Expiration = expiration } : null;
return await cache.GetOrCreateAsync( cacheable.CacheKey, async ct => { logger.LogInformation("Cache miss for {Key}", cacheable.CacheKey); return await next(); }, options, cancellationToken: cancellationToken); }}Queries that do not implement ICacheable pass through untouched. Queries that do get a two-tier cache (in-memory L1, optional Redis L2) for free. The handler never knows the cache exists.
TransactionBehavior
For commands that mutate the database, wrap them in an EF Core transaction with another opt-in marker:
public interface ITransactional;
public sealed record CreateProductCommand(string Name, decimal Price) : IRequest<Guid>, ITransactional;The behavior opens a transaction, calls next(), commits on success, rolls back on exception:
public sealed class TransactionBehavior<TRequest, TResponse>( AppDbContext db, ILogger<TransactionBehavior<TRequest, TResponse>> logger) : IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>{ public async ValueTask<TResponse> Handle( TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken) { if (request is not ITransactional || db.Database.IsInMemory()) { return await next(); }
await using var tx = await db.Database.BeginTransactionAsync(cancellationToken); try { var response = await next(); await tx.CommitAsync(cancellationToken); return response; } catch { logger.LogWarning("Rolling back transaction for {Request}", typeof(TRequest).Name); await tx.RollbackAsync(cancellationToken); throw; } }}Commands that do not implement ITransactional skip the transaction wrap entirely. The InMemory provider does not support real transactions, so the behavior no-ops on it for local development.
Get the point? That is four behaviors covering the four most common cross-cutting concerns in a CRUD API: logging, validation, caching, transactions. None of them know about each other. Each is open-generic and applies to every handler. Adding a fifth (metrics, retry, idempotency, audit) is one new file and one line in Program.cs.
Native AOT Support
The dispatcher uses reflection in exactly one place: at startup, in AddDispatcher, when scanning the assembly and building the wrapper instances. The hot dispatch path is reflection-free.
For Native AOT, that startup-time reflection is the part that needs care. Two options work:
- Hand-register the wrappers. Skip
Assembly.GetTypes()and call a generated registration function that adds eachRequestHandlerWrapper<TReq, TRes>explicitly. A small source generator can produce this list. This is the AOT-clean path. - Use a source generator library. martinothamar/Mediator does exactly this and ships full Native AOT support out of the box. If you want source-generated dispatch and you do not want to maintain the generator yourself, this is the level-up. It is MIT-licensed and benchmarked competitive with hand-rolled approaches.
Either way, the runtime characteristics are the same: zero reflection at dispatch, zero MakeGenericType allocations, zero MethodInfo.Invoke boxing. The FrozenDictionary lookup and the typed wrapper call are both AOT-friendly. The only thing AOT does not love is the startup-time Activator.CreateInstance(wrapperType) call, which the source-gen approach replaces with a direct new RequestHandlerWrapper<TReq, TRes>() per pair.
For most teams, the answer is: ship the reflection-based registration for normal AspNetCore deployments, switch to source generation only if you are publishing Native AOT for serverless or mobile.
Notifications and the In-Process Ceiling
The sample also includes a minimal INotification / IPublisher for in-process publish-subscribe:
public sealed record ProductCreatedNotification(Guid ProductId, string Name) : INotification;
public sealed class LogProductCreatedHandler(ILogger<LogProductCreatedHandler> logger) : INotificationHandler<ProductCreatedNotification>{ public ValueTask Handle(ProductCreatedNotification notification, CancellationToken cancellationToken) { logger.LogInformation("Product created: {Id} - {Name}", notification.ProductId, notification.Name); return ValueTask.CompletedTask; }}In the endpoint:
app.MapPost("/products", async ( CreateProductCommand command, ISender sender, IPublisher publisher, CancellationToken ct) =>{ var id = await sender.Send(command, ct); await publisher.Publish(new ProductCreatedNotification(id, command.Name), ct); return Results.Created($"/products/{id}", new { id });});This works exactly like MediatR’s Publish. Multiple INotificationHandler<ProductCreatedNotification> registrations all run, sequentially, in the same scope, on the same instance.
Haven’t we all been tempted to put everything inside notification handlers? This is also the ceiling. Notice what this approach cannot do:
- It cannot deliver the notification to a handler running on a different instance of your API.
- It cannot guarantee the notification handler runs even if the request crashes mid-publish.
- It cannot retry, schedule, or fan out to background workers.
- It cannot survive process restart.
If you need any of those properties, in-process notifications are not the right tool. You need a real message bus with an outbox. The cleanest pattern is to write the notification to an Outbox table inside the same EF Core transaction as the command, and let a separate worker pick it up and deliver it. Wolverine and MassTransit both ship this pattern out of the box. You can keep using your custom dispatcher for in-process commands and queries, and add a bus only for the events that need to escape the process.
The rule again: in-process notifications are fine for “do this other thing right now in the same request.” They are wrong for “make sure this happens reliably even if the process dies.” Most teams confuse the two and end up putting await emailService.Send(...) inside a notification handler, which means the request is now blocking on SMTP. Move that to a bus the moment you care about reliability.
Migration Recipe: From MediatR to the Custom Dispatcher
If you have an existing MediatR project, the migration is mostly mechanical because the interface shapes are deliberately compatible. Here is the recipe:
- Add the dispatcher folder (six files:
IRequest.cs,IRequestHandler.cs,IPipelineBehavior.cs,ISender.cs,INotification.cs,Dispatcher.cs, plusRequestHandlerWrapper.csandDispatcherRegistration.cs). Copy them from the repo. - Find and replace
using MediatR;withusing YourApp.Dispatcher;across the codebase. - Find and replace
Task<withValueTask<in every handler’sHandlemethod signature. The handler bodies do not change. - Replace
services.AddMediatR(...)withservices.AddDispatcher(Assembly.GetExecutingAssembly())inProgram.cs. Keep the same assembly scanning behavior. - Replace
services.AddTransient(typeof(IPipelineBehavior<,>), ...)withservices.AddPipelineBehavior(typeof(...))for each behavior. Order is preserved. - Remove the
MediatRandMediatR.Extensions.Microsoft.DependencyInjectionpackage references from.csproj. - Run the test suite. Handler unit tests pass unchanged because the interfaces are shape-compatible. Endpoint integration tests pass unchanged because
ISender.Sendstill exists.
The two gotchas I have hit on real migrations:
- Behavior order is registration order. MediatR’s behavior order is also registration order, but if you previously relied on
services.AddTransientordering quirks, double-check the new order. - Notification handlers run sequentially and in scope. MediatR has multiple publisher strategies (sequential, parallel, exception-aggregating). The custom dispatcher in this article only ships sequential. If you used parallel publish, you need to swap
foreach (var handler in handlers)forTask.WhenAll(handlers.Select(h => h.Handle(...).AsTask()))inNotificationHandlerWrapper.
For most projects, the entire migration is 30 minutes and a couple of hundred file changes that all look the same. The handler logic, the validators, the cache keys, and the endpoint signatures stay identical.
The Alternatives Landscape
If rolling your own is not the right answer for your team, here is the honest comparison of every alternative I evaluated:
| Library | License | Approach | Pipeline Behaviors | Distributed | Native AOT | Use it when |
|---|---|---|---|---|---|---|
| Custom dispatcher (this article) | Yours | FrozenDictionary + ValueTask | Yes, recursive lambda | No | With source gen | You want full control and 100 lines of code |
| martinothamar/Mediator | MIT | Source generator | Yes | No | Yes, native | You want source-gen perf without maintaining the generator |
| SwitchMediator | MIT | Source generator | Yes | No | Yes | You want a near drop-in MediatR-API replacement |
| Cortex.Mediator | MIT | Reflection (similar to MediatR) | Yes | No | Partial | You want a free, MIT-licensed MediatR clone |
| Wolverine | MIT | Source generator | Yes (richer) | Yes | Partial | You also need messaging, sagas, outbox |
| Brighter + Darker | MIT | Reflection | Yes | Yes | No | You want explicit command/query separation and a real bus |
| FastEndpoints | MIT | Endpoint-as-class | N/A (different model) | No | Yes | You can also drop the controller layer |
| MediatR 12.x | MIT (frozen) | Reflection + closed-generic cache | Yes | No | Limited | You are happy on 12.x and do not need new features |
| MediatR 13+ | Commercial | Reflection + closed-generic cache | Yes | No | Limited | You can pay for it and want vendor support |
The decision matrix that comes out of this:
- You want a tiny, controllable in-process dispatcher you understand top to bottom: build it yourself with this article’s approach. 100 lines, 4x faster than MediatR, MIT in your repo because you wrote it.
- You want source-gen performance without maintenance: use martinothamar/Mediator. It is benchmarked competitive, AOT-clean, MIT-licensed, and actively maintained.
- You also need messaging, sagas, outbox, scheduled jobs: use Wolverine. It can replace MediatR and handle distributed messaging with one library and one set of conventions.
- You want to escape MediatR with the smallest possible footprint and keep almost the same API: use SwitchMediator or Cortex.Mediator.
- You want to drop both the controller layer and the dispatcher: use FastEndpoints. Each endpoint becomes a class with a
HandleAsyncmethod, no dispatcher in between.
There is no wrong answer in this list. There is a right answer for your team’s appetite for code ownership versus library maintenance.
Verdict
In .NET 10, the right way to replace MediatR for in-process CQRS is a FrozenDictionary-backed dispatcher with ValueTask<T> returns and recursive lambda pipeline composition. It is 4.4x faster than MediatR 12.4.1, allocates 8.3x less memory per call, fits in roughly 100 lines of code, supports the same pipeline behavior pattern, and migrates from MediatR with a using-statement swap. If you also need cross-instance messaging, layer Wolverine on top. If you want source generation for Native AOT, use martinothamar/Mediator. If you do not need behaviors at all, use raw handlers.
The thing not to do is ship a 30-line reflection dispatcher and call it done. Every benchmark in this article points to the same conclusion: MakeGenericType plus GetMethod().Invoke() produces a dispatcher that is 2.9x slower than the MediatR you were trying to escape. The point of building your own is to be better, not just different.
Key Takeaways
- MediatR launched its commercial editions on July 2, 2025 under Lucky Penny Software. Versions 12 and earlier remain under their original open-source licenses (MIT, then Apache 2.0 from 12.5.0).
- CQRS and MediatR are separate concerns. You can do CQRS with raw handlers, FastEndpoints, Wolverine, or a 100-line custom dispatcher.
- A
FrozenDictionary-backed dispatcher withValueTask<T>is 4.4x faster than MediatR 12.4.1 and 12.9x faster than the naive reflection approach. - In-process dispatchers run per-request, per-instance. Multi-instance scaling does not require a distributed mediator. Distributed messaging is only required for cross-instance fan-out, sagas, and the outbox pattern.
- Pipeline behaviors compose with a recursive lambda chain that wraps the handler in registration order.
- Native AOT is achievable today by hand-registering wrappers or using martinothamar/Mediator for source-gen registration.
Troubleshooting
“No handler registered for request type ‘X’”
The dispatcher throws InvalidOperationException when it cannot find a wrapper for the request type. This means AddDispatcher did not pick up the handler during assembly scanning. Check that the handler class is in the same assembly you passed to AddDispatcher(Assembly.GetExecutingAssembly()). If the handler lives in a different project, pass that project’s assembly explicitly. Also verify the handler implements IRequestHandler<TRequest, TResponse> with the correct generic arguments - a typo in the response type creates a different registration key.
Pipeline behaviors run in the wrong order
Behavior execution order matches registration order in Program.cs. The first call to AddPipelineBehavior registers the outermost behavior. If your logging behavior is seeing cached responses instead of raw handler output, it is registered after the caching behavior. Move AddPipelineBehavior(typeof(LoggingBehavior<,>)) above AddPipelineBehavior(typeof(CachingBehavior<,>)) so logging wraps the entire chain.
ValueTask<T> throws “cannot be awaited multiple times”
ValueTask<T> is single-consumption by design. If a pipeline behavior awaits next() more than once (for example, in a retry loop), it will throw. Either convert to Task<T> with .AsTask() before the retry loop, or restructure so that each retry calls the full pipeline fresh instead of re-awaiting the same ValueTask.
Activator.CreateInstance fails under Native AOT trimming
The startup-time Activator.CreateInstance(wrapperType) call uses reflection that the AOT trimmer cannot statically analyze. The fix is to replace assembly scanning with explicit hand-registration or use a source generator like martinothamar/Mediator. See the Native AOT Support section for both approaches.
Notification handlers swallow exceptions silently
The default sequential publish loop in NotificationHandlerWrapper lets exceptions propagate immediately, which means the second handler never runs if the first throws. If you want all handlers to run regardless of failures, wrap each Handle call in a try/catch, collect the exceptions, and throw an AggregateException after the loop completes. MediatR’s TaskWhenAllPublisher does the same thing.
FrozenDictionary is not available on older target frameworks
FrozenDictionary<TKey, TValue> shipped in .NET 8. If you are targeting .NET 6 or 7, use ImmutableDictionary or a ReadOnlyDictionary backed by a regular Dictionary. The performance gap is small because the dictionary is only read on the hot path, never written. FrozenDictionary is faster for reads, but any frozen-after-construction dictionary gives you the same architectural benefit.
Frequently Asked Questions
Is MediatR still free in 2026?
MediatR 12.x and earlier remain under their original open-source licenses (MIT for earlier versions, Apache 2.0 for 12.5.0) and are free to use. MediatR 13 and onward, released after July 2, 2025, ship under a dual license that requires a commercial tier for many professional uses, with a free community tier for individuals and non-commercial use. The commercial editions are owned by Lucky Penny Software, founded by MediatR creator Jimmy Bogard.
Do I need MediatR to implement CQRS?
No. CQRS is an architectural pattern that separates reads from writes, and is implementation-agnostic. MediatR is one in-process dispatcher implementation. You can implement CQRS with raw handler classes, FastEndpoints, Wolverine, Brighter, or a custom 100-line dispatcher like the one in this article.
How fast is a custom CQRS dispatcher compared to MediatR?
On .NET 10 with BenchmarkDotNet 0.15.4, a FrozenDictionary-backed dispatcher with ValueTask returns runs in 11.5 nanoseconds per call versus MediatR 12.4.1 at 50.4 nanoseconds. That is 4.4 times faster, with 8.3 times less memory allocated per call (24 bytes versus 200 bytes).
Can I use a custom in-process mediator across multiple application instances?
Yes. In-process dispatchers run per-request, per-instance. The load balancer routes each HTTP request to one instance, and that instance's dispatcher handles it. Multi-instance scaling does not require a distributed mediator. You only need a real message bus when you need cross-instance fan-out, sagas, or the outbox pattern for reliable delivery.
How do I implement pipeline behaviors without MediatR?
Define an IPipelineBehavior interface with a Handle method that takes the request, a next delegate, and a cancellation token. At dispatch time, build a recursive lambda chain that wraps the handler in registration order. The first registered behavior runs outermost and the handler runs innermost. Each behavior can short-circuit by not calling next, or transform the response by awaiting it.
Is the mediator pattern in-memory only?
The mediator pattern can be implemented in-process or distributed. MediatR and most lightweight alternatives are in-process only, meaning they dispatch within the same application instance. Distributed mediators like Wolverine, MassTransit, and Brighter use a real message broker for cross-process delivery. For most CRUD APIs, an in-process mediator is sufficient because horizontal scaling happens at the load balancer.
What is the best MediatR alternative for Native AOT?
The martinothamar Mediator library uses C# source generators to produce strongly-typed dispatch code at compile time, with full Native AOT support. SwitchMediator is another zero-allocation, AOT-friendly source-generated alternative. A hand-rolled FrozenDictionary dispatcher can also be made AOT-clean by hand-registering wrappers instead of using assembly scanning.
How long does it take to migrate a project off MediatR?
For most projects, the migration is about 30 minutes of mechanical work. The interface shapes in this article are deliberately compatible with MediatR 12, so the steps are: copy the dispatcher files, find-and-replace the using statement, change Task return types to ValueTask in handlers, swap AddMediatR for AddDispatcher in Program.cs, and re-register pipeline behaviors. Handler bodies, validators, and endpoint signatures stay identical.
Wrapping Up
CQRS without MediatR is not just possible in .NET 10 - it is the better default for most teams. A 100-line FrozenDictionary dispatcher gives you four times the performance, AOT-readiness with a small extra step, and complete control over your code. Pipeline behaviors compose cleanly. Migration from MediatR is a using-statement swap. Multi-instance scaling does not need anything fancy because in-process dispatchers run per-request, per-instance, and the load balancer does the rest.
The full runnable code, including the BenchmarkDotNet project and the four pipeline behaviors, is at github.com/codewithmukesh/dotnet-webapi-zero-to-hero-course. Clone it, run dotnet run -c Release in the CqrsCustom.Benchmarks folder, and reproduce the numbers on your own hardware.
If you are still on MediatR 13+ or thinking about paying for the commercial license, give the custom approach a weekend. You will probably find that the dispatcher you build is faster, smaller, and easier to reason about than the library you were paying to use.
If you found this helpful, share it with your colleagues - and if there is a topic you would like to see covered next, drop a comment and let me know.
Essential .NET Libraries 2026
The full landscape of libraries I rely on in production .NET projects.
Happy Coding :)



What's your Feedback?
Do let me know your thoughts around this article.