VOOZH about

URL: https://codewithmukesh.com/blog/cqrs-without-mediatr/

⇱ Build Your Own CQRS Dispatcher in .NET 10 (No MediatR) - codewithmukesh


Skip to main content
Article complete

Get one like this every Tuesday at 7 PM IST.

Back to blog
dotnet 24 min read Lesson 38/128

Build Your Own CQRS Dispatcher in .NET 10 (No MediatR)

MediatR went commercial. Build your own CQRS dispatcher in .NET 10 with pipeline behaviors, AOT support, and a FrozenDictionary core that benchmarks 4x faster than MediatR.

MediatR went commercial. Build your own CQRS dispatcher in .NET 10 with pipeline behaviors, AOT support, and a FrozenDictionary core that benchmarks 4x faster than MediatR.

dotnet

cqrs mediatr alternative dispatcher dotnet 10 aspnet core pipeline behavior frozendictionary valuetask native aot clean architecture vertical slice architecture minimal api benchmark mediator pattern command query separation fluentvalidation hybridcache in-process messaging migration

👁 Mukesh Murugan
Mukesh Murugan
Software Engineer
Chapter · 38 of 128 Module 3 of 12 Free
View course

.NET Web API Zero to Hero Course

From dotnet new to docker push — REST, EF Core 10, auth, caching, Clean Architecture, observability. 128 hands-on lessons, source on GitHub.

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.

Read nextCompanion article

Background: CQRS with MediatR in ASP.NET Core

Complete walkthrough of the CQRS pattern with commands, queries, notifications, and pipeline behaviors.

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 (
CreateProductCommandcmd,
CreateProductCommandHandlerhandler,
CancellationTokenct) =>
{
varid=awaithandler.Handle(cmd, ct);
returnResults.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:

ConcernSolved by
Sending a command or query to a handler in the same processIn-process dispatcher (MediatR, our custom one, raw handlers)
Fanning a domain event out to handlers on other instancesDistributed message bus (Wolverine, MassTransit, Brighter, NServiceBus)
Reliable delivery and outbox guarantees across processesBus + transactional outbox
Long-running workflows that survive process restartsSagas

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:

  1. Raw method call. Direct invocation of handler.Handle(request, ct). The baseline.
  2. Reflection dispatcher. The naive build-your-own that almost every blog ships: MakeGenericType + GetMethod + Invoke. This is what the top SERP results all use.
  3. FrozenDictionary dispatcher. The approach I am going to walk through in this article.
  4. 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.

DispatcherMeanRatioAllocated
Raw method call (baseline)0.054 ns1.000 B
FrozenDictionary dispatcher11.476 ns214.5124 B
MediatR 12.4.150.411 ns942.27200 B
Reflection dispatcher148.535 ns2,776.37288 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, no GetMethod, no MethodInfo.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:

Terminal window
cdCqrsCustom.Benchmarks
dotnetrun-cRelease

The 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.

Dispatcher/IRequest.cs
publicinterfaceIRequest<outTResponse>;
publicinterfaceIRequest : IRequest<Unit>;
publicreadonlystructUnit
{
publicstaticreadonlyUnitValue=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.

Dispatcher/IRequestHandler.cs
publicinterfaceIRequestHandler<inTRequest, TResponse>
whereTRequest : IRequest<TResponse>
{
ValueTask<TResponse> Handle(TRequestrequest, CancellationTokencancellationToken);
}

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.

Dispatcher/IPipelineBehavior.cs
publicdelegateValueTask<TResponse> RequestHandlerDelegate<TResponse>();
publicinterfaceIPipelineBehavior<inTRequest, TResponse>
whereTRequest : IRequest<TResponse>
{
ValueTask<TResponse> Handle(
TRequestrequest,
RequestHandlerDelegate<TResponse> next,
CancellationTokencancellationToken);
}

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.

Dispatcher/ISender.cs
publicinterfaceISender
{
ValueTask<TResponse> Send<TResponse>(
IRequest<TResponse> request,
CancellationTokencancellationToken=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:

Dispatcher/RequestHandlerWrapper.cs
internalabstractclassRequestHandlerBase;
internalabstractclassRequestHandlerBase<TResponse> : RequestHandlerBase
{
publicabstractValueTask<TResponse> Handle(
IRequest<TResponse> request,
IServiceProviderprovider,
CancellationTokencancellationToken);
}
internalsealedclassRequestHandlerWrapper<TRequest, TResponse> : RequestHandlerBase<TResponse>
whereTRequest : IRequest<TResponse>
{
publicoverrideValueTask<TResponse> Handle(
IRequest<TResponse> request,
IServiceProviderprovider,
CancellationTokencancellationToken)
{
vartyped= (TRequest)request;
varhandler=provider.GetRequiredService<IRequestHandler<TRequest, TResponse>>();
varbehaviors=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 (varbehaviorinbehaviors.Reverse())
{
varnext=pipeline;
varcurrent=behavior;
pipeline= () =>current.Handle(typed, next, cancellationToken);
}
returnpipeline();
}
}

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:

Dispatcher/Dispatcher.cs
internalsealedclassDispatcher(
IServiceProviderprovider,
DispatcherRegistryregistry) : ISender, IPublisher
{
publicValueTask<TResponse> Send<TResponse>(
IRequest<TResponse> request,
CancellationTokencancellationToken=default)
{
ArgumentNullException.ThrowIfNull(request);
if (!registry.RequestWrappers.TryGetValue(request.GetType(), outvarwrapper))
{
thrownewInvalidOperationException(
$"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
}
internalsealedclassDispatcherRegistry(
FrozenDictionary<Type, RequestHandlerBase> requestWrappers,
FrozenDictionary<Type, NotificationHandlerBase> notificationWrappers)
{
publicFrozenDictionary<Type, RequestHandlerBase> RequestWrappers { get; } =requestWrappers;
publicFrozenDictionary<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:

Dispatcher/DispatcherRegistration.cs
publicstaticIServiceCollectionAddDispatcher(thisIServiceCollectionservices, Assemblyassembly)
{
varrequestWrappers=newDictionary<Type, RequestHandlerBase>();
varnotificationWrappers=newDictionary<Type, NotificationHandlerBase>();
foreach (vartypeinassembly.GetTypes())
{
if (type.IsAbstract||type.IsInterface) continue;
foreach (varifaceintype.GetInterfaces())
{
if (!iface.IsGenericType) continue;
vardef=iface.GetGenericTypeDefinition();
if (def==typeof(IRequestHandler<,>))
{
services.AddScoped(iface, type);
varargs=iface.GetGenericArguments();
varrequestType=args[0];
varresponseType=args[1];
if (!requestWrappers.ContainsKey(requestType))
{
varwrapperType=typeof(RequestHandlerWrapper<,>)
.MakeGenericType(requestType, responseType);
requestWrappers[requestType] =
(RequestHandlerBase)Activator.CreateInstance(wrapperType)!;
}
}
// ... INotificationHandler branch omitted, see repo
}
}
varregistry=newDispatcherRegistry(
requestWrappers.ToFrozenDictionary(),
notificationWrappers.ToFrozenDictionary());
services.AddSingleton(registry);
services.AddScoped<Dispatcher>();
services.AddScoped<ISender>(sp=>sp.GetRequiredService<Dispatcher>());
services.AddScoped<IPublisher>(sp=>sp.GetRequiredService<Dispatcher>());
returnservices;
}
publicstaticIServiceCollectionAddPipelineBehavior(
thisIServiceCollectionservices,
TypeopenGenericBehaviorType)
{
services.AddScoped(typeof(IPipelineBehavior<,>), openGenericBehaviorType);
returnservices;
}

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

publicsealedclassLoggingBehavior<TRequest, TResponse>(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
: IPipelineBehavior<TRequest, TResponse>
whereTRequest : IRequest<TResponse>
{
publicasyncValueTask<TResponse> Handle(
TRequestrequest,
RequestHandlerDelegate<TResponse> next,
CancellationTokencancellationToken)
{
varrequestName=typeof(TRequest).Name;
logger.LogInformation("Handling {RequestName}", requestName);
varsw=Stopwatch.StartNew();
try
{
varresponse=awaitnext();
sw.Stop();
logger.LogInformation("Handled {RequestName} in {Elapsed}ms", requestName, sw.ElapsedMilliseconds);
returnresponse;
}
catch (Exceptionex)
{
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

publicsealedclassValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse>
whereTRequest : IRequest<TResponse>
{
publicasyncValueTask<TResponse> Handle(
TRequestrequest,
RequestHandlerDelegate<TResponse> next,
CancellationTokencancellationToken)
{
if (!validators.Any())
{
returnawaitnext();
}
varcontext=newValidationContext<TRequest>(request);
varfailures=newList<FluentValidation.Results.ValidationFailure>();
foreach (varvalidatorinvalidators)
{
varresult=awaitvalidator.ValidateAsync(context, cancellationToken);
if (!result.IsValid)
{
failures.AddRange(result.Errors);
}
}
if (failures.Count>0)
{
thrownewValidationException(failures);
}
returnawaitnext();
}
}

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.

Read nextCompanion article

FluentValidation in ASP.NET Core

Setup, rules, async validation, and DI registration for FluentValidation in .NET 10.

CachingBehavior with HybridCache

For queries that should be cached, mark them with an opt-in interface:

publicinterfaceICacheable
{
stringCacheKey { get; }
TimeSpan? Expiration=>null;
}
publicsealedrecordGetProductQuery(GuidId) : IRequest<ProductDto?>, ICacheable
{
publicstringCacheKey=>$"product:{Id}";
publicTimeSpan? Expiration=>TimeSpan.FromMinutes(5);
}

The behavior wraps the handler with a HybridCache lookup:

publicsealedclassCachingBehavior<TRequest, TResponse>(
HybridCachecache,
ILogger<CachingBehavior<TRequest, TResponse>> logger)
: IPipelineBehavior<TRequest, TResponse>
whereTRequest : IRequest<TResponse>
{
publicasyncValueTask<TResponse> Handle(
TRequestrequest,
RequestHandlerDelegate<TResponse> next,
CancellationTokencancellationToken)
{
if (requestisnotICacheablecacheable)
{
returnawaitnext();
}
varoptions=cacheable.Expirationis { } expiration
?newHybridCacheEntryOptions { Expiration=expiration }
:null;
returnawaitcache.GetOrCreateAsync(
cacheable.CacheKey,
asyncct=>
{
logger.LogInformation("Cache miss for {Key}", cacheable.CacheKey);
returnawaitnext();
},
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.

Read nextCompanion article

HybridCache in ASP.NET Core

Two-tier caching in .NET 10 with the new HybridCache API - L1 in-memory, optional L2 distributed.

TransactionBehavior

For commands that mutate the database, wrap them in an EF Core transaction with another opt-in marker:

publicinterfaceITransactional;
publicsealedrecordCreateProductCommand(stringName, decimalPrice)
: IRequest<Guid>, ITransactional;

The behavior opens a transaction, calls next(), commits on success, rolls back on exception:

publicsealedclassTransactionBehavior<TRequest, TResponse>(
AppDbContextdb,
ILogger<TransactionBehavior<TRequest, TResponse>> logger)
: IPipelineBehavior<TRequest, TResponse>
whereTRequest : IRequest<TResponse>
{
publicasyncValueTask<TResponse> Handle(
TRequestrequest,
RequestHandlerDelegate<TResponse> next,
CancellationTokencancellationToken)
{
if (requestisnotITransactional||db.Database.IsInMemory())
{
returnawaitnext();
}
awaitusingvartx=awaitdb.Database.BeginTransactionAsync(cancellationToken);
try
{
varresponse=awaitnext();
awaittx.CommitAsync(cancellationToken);
returnresponse;
}
catch
{
logger.LogWarning("Rolling back transaction for {Request}", typeof(TRequest).Name);
awaittx.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:

  1. Hand-register the wrappers. Skip Assembly.GetTypes() and call a generated registration function that adds each RequestHandlerWrapper<TReq, TRes> explicitly. A small source generator can produce this list. This is the AOT-clean path.
  2. 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:

publicsealedrecordProductCreatedNotification(GuidProductId, stringName) : INotification;
publicsealedclassLogProductCreatedHandler(ILogger<LogProductCreatedHandler> logger)
: INotificationHandler<ProductCreatedNotification>
{
publicValueTaskHandle(ProductCreatedNotificationnotification, CancellationTokencancellationToken)
{
logger.LogInformation("Product created: {Id} - {Name}", notification.ProductId, notification.Name);
returnValueTask.CompletedTask;
}
}

In the endpoint:

app.MapPost("/products", async (
CreateProductCommandcommand, ISendersender, IPublisherpublisher, CancellationTokenct) =>
{
varid=awaitsender.Send(command, ct);
awaitpublisher.Publish(newProductCreatedNotification(id, command.Name), ct);
returnResults.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:

  1. Add the dispatcher folder (six files: IRequest.cs, IRequestHandler.cs, IPipelineBehavior.cs, ISender.cs, INotification.cs, Dispatcher.cs, plus RequestHandlerWrapper.cs and DispatcherRegistration.cs). Copy them from the repo.
  2. Find and replace using MediatR; with using YourApp.Dispatcher; across the codebase.
  3. Find and replace Task< with ValueTask< in every handler’s Handle method signature. The handler bodies do not change.
  4. Replace services.AddMediatR(...) with services.AddDispatcher(Assembly.GetExecutingAssembly()) in Program.cs. Keep the same assembly scanning behavior.
  5. Replace services.AddTransient(typeof(IPipelineBehavior<,>), ...) with services.AddPipelineBehavior(typeof(...)) for each behavior. Order is preserved.
  6. Remove the MediatR and MediatR.Extensions.Microsoft.DependencyInjection package references from .csproj.
  7. Run the test suite. Handler unit tests pass unchanged because the interfaces are shape-compatible. Endpoint integration tests pass unchanged because ISender.Send still 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.AddTransient ordering 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) for Task.WhenAll(handlers.Select(h => h.Handle(...).AsTask())) in NotificationHandlerWrapper.

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:

LibraryLicenseApproachPipeline BehaviorsDistributedNative AOTUse it when
Custom dispatcher (this article)YoursFrozenDictionary + ValueTaskYes, recursive lambdaNoWith source genYou want full control and 100 lines of code
martinothamar/MediatorMITSource generatorYesNoYes, nativeYou want source-gen perf without maintaining the generator
SwitchMediatorMITSource generatorYesNoYesYou want a near drop-in MediatR-API replacement
Cortex.MediatorMITReflection (similar to MediatR)YesNoPartialYou want a free, MIT-licensed MediatR clone
WolverineMITSource generatorYes (richer)YesPartialYou also need messaging, sagas, outbox
Brighter + DarkerMITReflectionYesYesNoYou want explicit command/query separation and a real bus
FastEndpointsMITEndpoint-as-classN/A (different model)NoYesYou can also drop the controller layer
MediatR 12.xMIT (frozen)Reflection + closed-generic cacheYesNoLimitedYou are happy on 12.x and do not need new features
MediatR 13+CommercialReflection + closed-generic cacheYesNoLimitedYou 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 HandleAsync method, 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 with ValueTask<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

Frequently asked08 questions

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.

Read nextCompanion article

Background: CQRS with MediatR in ASP.NET Core

Read the original CQRS pattern walkthrough first if you are new to commands, queries, and pipeline behaviors.

Read nextCompanion article

FluentValidation in ASP.NET Core

Pair the ValidationBehavior with FluentValidation for automatic input validation.

Read nextCompanion article

HybridCache in ASP.NET Core

The two-tier cache that powers the CachingBehavior in this article.

Read nextCompanion article

Validation with MediatR Pipeline Behavior

The pattern that inspired the ValidationBehavior, written for MediatR but conceptually identical.

Read nextCompanion article

Caching with MediatR

The MediatR-specific version of the CachingBehavior pattern.

Read nextCompanion article

Clean Architecture in .NET 10

Where to put the dispatcher in a clean architecture solution.

Read nextCompanion article

Global Exception Handling in ASP.NET Core

Pair the ValidationBehavior's ValidationException with a global exception handler that returns RFC 9457 ProblemDetails.

Read nextCompanion article

Middlewares in ASP.NET Core

The conceptual cousin of pipeline behaviors at the HTTP layer.

Happy Coding :)

More from the archive.

View all articles

What's your take?

Push back, share a war story, or ask the obvious question someone else is wondering. I read every comment.

View on GitHub

Weekly .NET tips · free

Subscribed · Tue 7 PM IST

You're in.
Welcome to the crew.

Tuesday's issue lands in your inbox at 7 PM IST. One last step: confirm your email so it actually arrives.

01 · Check your inbox

02 · Every Tuesday

Benchmarks, architecture insights, and production tips that never make it to the blog.

Privacy notice · 30s read

Cookies, but only the useful ones.

I use cookies to understand which articles get read and which CTAs actually work. No third-party advertising trackers, ever. Read the privacy policy →