Skip to content
Kumar Chandrachooda
.NET

Hand-Rolled MediatR in Thirty Lines

DShop.Common ships its own in-process command and query dispatch - marker interfaces, a handler triple, and one dynamic double-dispatch standing in for a library.

By Kumar Chandrachooda 06 Sep 2025 4 min read
A message resolving to its handler through a reflected generic type

Part seven closed the messaging half of the library. This part opens the other half: how a request gets from a controller to the code that handles it without touching the bus. MediatR — Jimmy Bogard's in-process messaging library — was already the default answer to that question in 2018, and DShop.Common declines it, reimplementing the core in about thirty lines of its own. Reading those thirty lines is a good way to understand what MediatR actually does, because this is MediatR with the lid off: the same marker interfaces, the same handler-per-message shape, and the same one trick at the centre that everybody's mediator eventually needs.

The shape: three markers, three handlers

The vocabulary is marker interfaces, exactly as with commands and events in part three. A query adds a result type:

//Marker
public interface IQuery { }
public interface IQuery<T> : IQuery { }

and each message kind gets a handler interface:

public interface ICommandHandler<in TCommand> where TCommand : ICommand
{
    Task HandleAsync(TCommand command, ICorrelationContext context);
}

public interface IQueryHandler<TQuery, TResult> where TQuery : IQuery<TResult>
{
    Task<TResult> HandleAsync(TQuery query);
}

Note the asymmetry, because it is deliberate and it matters: a command handler receives the correlation context; a query handler does not. Queries are reads — they answer a question and change nothing — so there is nothing to correlate across services and no side effect to trace. That is a clean modelling instinct. (Events, the third kind, have no in-process dispatcher at all — an IEventHandler<T> is only ever invoked by the bus subscriber from part five. In this estate, commands and queries are local; events are always distributed. The asymmetry is the CQRS seam drawn in dependency wiring.)

Commands: the easy half

CommandDispatcher is four lines of body:

public async Task SendAsync<T>(T command) where T : ICommand
    => await _context.Resolve<ICommandHandler<T>>()
        .HandleAsync(command, CorrelationContext.Empty);

Because T is a compile-time generic parameter, the container can resolve ICommandHandler<T> directly — no reflection needed. It is a service-locator-style dispatch (it reaches into Autofac's IComponentContext rather than being handed the handler), which purists dislike, but it is honest and legible. The one thing to flag is the second argument: CorrelationContext.Empty. The in-process command path throws the correlation context away. A command dispatched locally through this method carries no correlation id, no user, no trace — the observability spine that part four works so hard to thread through the bus is simply dropped on the local path. Wherever a controller sends a command in-process (the monolith does this pervasively), that command is invisible to correlation-based tracing. Not wrong, exactly — local calls are within one process and one log scope — but a real seam where the two dispatch models diverge in what they preserve.

Queries: the trick every mediator needs

Queries cannot be dispatched as cleanly, and the reason is the crux of the whole pattern. A caller holds an IQuery<TResult> — it knows the result type but not the concrete query type at compile time. You cannot write Resolve<IQueryHandler<TQuery, TResult>>() because you do not have TQuery. So QueryDispatcher reaches for reflection and dynamic:

public async Task<TResult> QueryAsync<TResult>(IQuery<TResult> query)
{
    var handlerType = typeof(IQueryHandler<,>)
        .MakeGenericType(query.GetType(), typeof(TResult));

    dynamic handler = _context.Resolve(handlerType);

    return await handler.HandleAsync((dynamic)query);
}

Read it slowly, because these five lines are doing something subtle:

  • query.GetType() recovers the concrete query type at runtime — the type the compiler lost when the query was upcast to IQuery<TResult>.
  • MakeGenericType builds the closed IQueryHandler<ConcreteQuery, TResult> type from the open generic.
  • Resolve(handlerType) asks Autofac for it, typed only as object, so it is caught in a dynamic variable.
  • handler.HandleAsync((dynamic)query) is a double dynamic dispatch: both the handler and the query are dynamic, so the runtime binder picks the correct HandleAsync overload for the concrete pair. It is the only way to make the call compile when neither type is statically known.

This is not naive code. It is exactly the manoeuvre MediatR performs internally (MediatR wraps it in generated handler-wrapper classes to cache the reflection, but the essential “recover the runtime type, close the open generic, invoke dynamically” is identical). Seeing it stripped of the caching is genuinely educational — this is why a mediator needs reflection at all.

The honest cost, to be fair about it: dynamic defers everything to runtime. A missing IQueryHandler registration is not a compile error; it is an Autofac resolution exception (or, if the shapes mismatch, a RuntimeBinderException) thrown the first time that query is issued in production. And every query pays a small dynamic-binding and reflection overhead that a source-generated or cached-delegate approach would avoid. For a teaching estate serving a demo shop, neither cost bites; in a hot path you would want MediatR's caching or a source generator. The technique is correct; only the ergonomics are traded away.

Why roll your own?

The fair question is why not just add MediatR. Two defensible reasons, and both fit the estate. First, pedagogy: this is the lesson — a student who reads these thirty lines understands dispatch in a way that services.AddMediatR() never teaches. Second, control: no external dependency to version across seventeen repos (a real concern, as the final part shows), and the dispatcher can be exactly as small as the estate needs. What you give up is MediatR's pipeline behaviours — the validation, logging and transaction decorators that wrap every handler — which this estate has to solve some other way (it mostly doesn't, and where it tries, you get the fluent handler that explodes on success).

That fluent handler is next — the estate's one attempt at a reusable try/catch/finally wrapper, registered in the container, used by nobody, and carrying a latent NullReferenceException that fires on the success path. A dead class with a live lesson about await and the null-conditional operator.