MediatR is a great library. I'm not here to trash it.
But when it moved to a paid model for commercial use, a lot of teams — including mine — had to make a decision. And the more I looked at what MediatR actually does under the hood, the more I realised: this is not complicated enough to justify an external dependency.
So I built it myself. Here's what that looks like, why I made each decision, and whether it was worth it.
What MediatR actually does
Strip away the marketing and MediatR does two things:
- Takes a request object and routes it to the right handler
- Runs a configurable pipeline of behaviors before and after the handler
That's it. The "mediator pattern" is mostly a fancy name for a dispatcher with middleware. Understanding this is the unlock — once you see it that way, building it yourself feels obvious.
The core abstractions
Everything starts with a request marker interface and its handler:
public interface IRequest<TResponse> { }
public interface IRequestHandler<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken);
}
Then I split commands and queries explicitly. This enforces CQRS at the type level — a command can't accidentally be used where a query is expected:
// Commands mutate state — always return Result or Result<T>
public interface ICommand : IRequest<Result> { }
public interface ICommand<TResponse> : IRequest<Result<TResponse>> { }
// Queries are read-only — always return Result<T>
public interface IQuery<TResponse> : IRequest<Result<TResponse>> { }
And their handlers:
public interface ICommandHandler<TCommand, TResponse>
: IRequestHandler<TCommand, Result<TResponse>>
where TCommand : ICommand<TResponse> { }
public interface IQueryHandler<TQuery, TResponse>
: IRequestHandler<TQuery, Result<TResponse>>
where TQuery : IQuery<TResponse> { }
Notice everything returns Result<T> — I'll come back to that.
The Dispatcher
This is the piece that does the actual routing. It resolves the handler from DI and chains pipeline behaviors around it:
internal sealed class Dispatcher(IServiceProvider serviceProvider) : IDispatcher
{
public async Task<TResponse> Send<TResponse>(
IRequest<TResponse> request,
CancellationToken cancellationToken)
{
var requestType = request.GetType();
var handlerType = typeof(IRequestHandler<,>)
.MakeGenericType(requestType, typeof(TResponse));
var handler = serviceProvider.GetRequiredService(handlerType);
var behaviors = serviceProvider
.GetServices(typeof(IPipelineBehavior<,>)
.MakeGenericType(requestType, typeof(TResponse)))
.Cast<dynamic>()
.Reverse()
.ToList();
RequestHandlerDelegate<TResponse> pipeline = ct =>
{
var method = handlerType.GetMethod(nameof(IRequestHandler<IRequest<TResponse>, TResponse>.Handle))!;
return (Task<TResponse>)method.Invoke(handler, [request, ct])!;
};
pipeline = behaviors.Aggregate(pipeline, (next, behavior) =>
ct => behavior.Handle((dynamic)request, next, ct));
return await pipeline(cancellationToken);
}
}
The behaviors are registered in order in DI. Each one wraps the next, forming a chain. The innermost call is always the handler itself. This is the same mental model as ASP.NET Core middleware — if you understand that, you understand this.
The pipeline
Pipeline behaviors implement a single interface:
public delegate Task<TResponse> RequestHandlerDelegate<TResponse>(CancellationToken cancellationToken);
public interface IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken);
}
I have two behaviors registered: ValidationBehavior (runs first) and LoggingBehavior (wraps everything). Adding a new cross-cutting concern — say, caching or timing — is one class and one line in DependencyInjection.cs. No library update required.
The pipeline order in DI registration:
ValidationBehavior → LoggingBehavior → Handler
Why Result instead of exceptions
This is a separate decision but it compounds well with CQRS. Every handler returns Result or Result<T>. Expected business failures — email already in use, entity not found — are values, not exceptions.
public async Task<Result<Guid>> Handle(
RegisterClientCommand request,
CancellationToken cancellationToken)
{
var emailExists = await repository.ExistsByEmailAsync(request.Email, cancellationToken);
if (emailExists)
return Result.Failure<Guid>(new ConflictError("Client.EmailInUse", "Email is already in use."));
var client = Client.Create(request.FirstName, request.LastName, request.Email);
await repository.InsertAsync(client, cancellationToken);
return Result.Success(client.Id);
}
The controller never needs a try/catch. It maps the result to HTTP via an extension method:
[HttpPost]
public async Task<IActionResult> Register(
[FromBody] RegisterClientCommand command,
CancellationToken cancellationToken)
{
var result = await dispatcher.Send(command, cancellationToken);
return result.IsFailure
? result.ToActionResult()
: CreatedAtAction(nameof(GetById), new { id = result.Value }, null);
}
ToActionResult() maps DomainError.ErrorType to HTTP status codes in one place. NotFoundError → 404, ConflictError → 409, ValidationError → 422. The controller doesn't know about any of this — it just unwraps the result.
DI registration — zero ceremony
One of MediatR's conveniences is auto-registering handlers via assembly scanning. I replicated this in about 15 lines:
private static void RegisterHandlers(IServiceCollection services, Assembly assembly, Type openHandlerType)
{
var handlers = assembly.GetTypes()
.Where(t => t is { IsAbstract: false, IsInterface: false })
.SelectMany(t => t.GetInterfaces(), (impl, iface) => (impl, iface))
.Where(x => x.iface.IsGenericType &&
x.iface.GetGenericTypeDefinition() == openHandlerType);
foreach (var (impl, iface) in handlers)
{
services.AddScoped(iface, impl);
var requestHandlerType = iface.GetInterfaces()
.FirstOrDefault(i => i.IsGenericType &&
i.GetGenericTypeDefinition() == typeof(IRequestHandler<,>));
if (requestHandlerType is not null)
services.AddScoped(requestHandlerType, impl);
}
}
Adding a new feature — a new command or query — requires zero changes to this registration. Write the handler, implement the interface, done.
Was it worth it?
Honestly, yes — but with an asterisk.
What you gain:
- No licensing concerns, ever
- Full visibility into how dispatch and pipeline work
- Easier to debug — the stack trace is your code, not a library's internals
- You can extend it in ways MediatR doesn't support without hacks
What you give up:
- MediatR has years of edge cases handled
- The ecosystem assumes MediatR (blog posts, templates, other libraries)
- New team members have to learn your implementation
If you're starting a greenfield project with a stable team, building it yourself is a reasonable call. If you're onboarding contractors every quarter, the familiarity of MediatR has real value.
For me, migrating a production system and wanting full control over every layer — it was the right decision.
The full implementation
Everything in this article is part of a working .NET 10 template I published on GitHub, including Clean Architecture layers, EF Core + Dapper, FluentValidation pipeline, and a full test suite (unit, integration, E2E with Testcontainers).
github.com/ferras991/dotnet-clean-arch-cqrs
If you've been reaching for MediatR out of habit, it might be worth asking whether you actually need it.
Top comments (0)