DEV Community

Pedro Ferreira
Pedro Ferreira

Posted on

Why I Stopped Using MediatR and Built CQRS From Scratch in .NET 10

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:

  1. Takes a request object and routes it to the right handler
  2. 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);
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

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)