惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

阮一峰的网络日志
阮一峰的网络日志
J
Java Code Geeks
Martin Fowler
Martin Fowler
宝玉的分享
宝玉的分享
V
Visual Studio Blog
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
M
MIT News - Artificial intelligence
U
Unit 42
博客园 - 三生石上(FineUI控件)
博客园 - 聂微东
The GitHub Blog
The GitHub Blog
I
InfoQ
WordPress大学
WordPress大学
H
Help Net Security
D
Docker
B
Blog
腾讯CDC
A
About on SuperTechFans
Recent Announcements
Recent Announcements
雷峰网
雷峰网
有赞技术团队
有赞技术团队
C
Check Point Blog
Y
Y Combinator Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Why I Stopped Using MediatR and Built CQRS From Scratch i...
Pedro Ferrei · 2026-05-03 · via DEV Community

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.