Featured image of post Ditching MediatR? Maybe a Simple CQRS Dispatcher is All You Need

Ditching MediatR? Maybe a Simple CQRS Dispatcher is All You Need

Exploring how to implement the CQRS pattern using a simple, custom dispatcher with .NET's built-in dependency injection, offering an alternative to libraries like MediatR, especially in light of licensing changes.

Alright, let’s talk about MediatR. You’ve probably heard the news – the popular .NET library, often the go-to for CQRS-style patterns, is shifting towards a commercial license for commercial use. Now, fair play to the maintainer for sustainability, but it definitely got me thinking. Do we really need a library like MediatR for decoupling our commands and queries? Often, the answer is simpler than you think.

This isn’t just about licensing costs; it’s a good chance to look at the complexity we sometimes invite into our codebases.

That Time MediatR Became the Maze Runner

I remember one project vividly. It started clean, using MediatR to nicely separate commands/queries from handlers. Standard stuff. But over time, it grew… wild. We had MediatR requests triggering other MediatR requests, sometimes nested. Notifications were flying everywhere, kicking off background jobs or updating read models in ways that weren’t immediately obvious.

Debugging became a nightmare. Trying to trace a single user action felt like navigating a maze. Where did the request actually go? Which notification handlers fired? Were they essential, or just side effects added later? We spent so much time just trying to understand the flow, time that could have been spent building features. It wasn’t MediatR’s fault per se, but the perceived “magic” it offered made it too easy to create tangled dependencies and obscure the core logic. It highlighted a big downside: when things get complex, that layer of abstraction can hinder understanding rather than help.

Quick Refresher: CQRS != MediatR

Just to be clear: CQRS (Command Query Responsibility Segregation) is a pattern. It’s about separating your write operations (Commands) from your read operations (Queries). MediatR is just one tool you can use to implement the dispatching part of that pattern. You absolutely don’t need it.

The core idea is simple: send a message (command/query) and have the right piece of code handle it. We can totally do that ourselves.

Rolling Your Own: A Simple Dispatcher Pattern

Let’s sketch out a lean, mean CQRS dispatcher using basic C# and the built-in dependency injection container.

1. Define Your Messages (Plain Old C# Objects)

Commands and Queries are just data carriers. Records are great for this.

1
2
3
4
5
6
7
// Command: Do something
public record CreateProductCommand(string Name, decimal Price);

// Query: Ask for something
public record GetProductByIdQuery(Guid Id);
// Query Result: The data you get back
public record ProductDto(Guid Id, string Name, decimal Price);

2. Define Your Handlers (Interfaces + Classes)

Simple interfaces make it clear what handles what.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Handles commands (no return value typically)
public interface ICommandHandler<TCommand>
{
    Task HandleAsync(TCommand command, CancellationToken cancellationToken = default);
}

// Handles queries (returns a result)
public interface IQueryHandler<TQuery, TResult>
{
    Task<TResult> HandleAsync(TQuery query, CancellationToken cancellationToken = default);
}

And the implementations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Example Command Handler
public class CreateProductCommandHandler : ICommandHandler<CreateProductCommand>
{
    // Inject your DbContext, repositories, services here...
    public async Task HandleAsync(CreateProductCommand command, CancellationToken cancellationToken = default)
    {
        Console.WriteLine($"Creating product: {command.Name}");
        // ... your actual logic to save the product
        await Task.CompletedTask; // Placeholder
    }
}

// Example Query Handler
public class GetProductByIdQueryHandler : IQueryHandler<GetProductByIdQuery, ProductDto>
{
    // Inject your read-specific dependencies...
    public async Task<ProductDto> HandleAsync(GetProductByIdQuery query, CancellationToken cancellationToken = default)
    {
        Console.WriteLine($"Fetching product: {query.Id}");
        // ... your actual logic to fetch the product
        // Example return:
        return await Task.FromResult(new ProductDto(query.Id, "Sample Product", 19.99m));
    }
}

3. The Dispatcher: Connecting the Dots

This is the heart of it. A simple service that uses the DI container to find and run the right handler.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public interface IDispatcher
{
    Task SendAsync<TCommand>(TCommand command, CancellationToken cancellationToken = default) where TCommand : notnull;
    Task<TResult> QueryAsync<TQuery, TResult>(TQuery query, CancellationToken cancellationToken = default) where TQuery : notnull;
}

public class DependencyInjectionDispatcher : IDispatcher
{
    private readonly IServiceProvider _serviceProvider;

    public DependencyInjectionDispatcher(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public Task SendAsync<TCommand>(TCommand command, CancellationToken cancellationToken = default) where TCommand : notnull
    {
        // Find the right command handler
        var handler = _serviceProvider.GetRequiredService<ICommandHandler<TCommand>>();
        return handler.HandleAsync(command, cancellationToken);
    }

    public Task<TResult> QueryAsync<TQuery, TResult>(TQuery query, CancellationToken cancellationToken = default) where TQuery : notnull
    {
        // Find the right query handler
        var handler = _serviceProvider.GetRequiredService<IQueryHandler<TQuery, TResult>>();
        return handler.HandleAsync(query, cancellationToken);
    }
}

4. Wire it Up (Dependency Injection)

Register your handlers and the dispatcher. Using a library like Scrutor makes assembly scanning easy, but you can register them manually too.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// In Program.cs or Startup.cs

// Example using Scrutor for scanning:
services.Scan(scan => scan
    .FromAssemblies(Assembly.GetExecutingAssembly()) // Or specify your assembly
        .AddClasses(classes => classes.AssignableTo(typeof(ICommandHandler<>)))
            .AsImplementedInterfaces()
            .WithScopedLifetime() // Or Transient/Singleton as needed
        .AddClasses(classes => classes.AssignableTo(typeof(IQueryHandler<,>)))
            .AsImplementedInterfaces()
            .WithScopedLifetime()); // Or Transient/Singleton as needed

// Register the dispatcher itself
services.AddScoped<IDispatcher, DependencyInjectionDispatcher>();

// If not using scanning, you'd register each handler manually:
// services.AddScoped<ICommandHandler<CreateProductCommand>, CreateProductCommandHandler>();
// services.AddScoped<IQueryHandler<GetProductByIdQuery, ProductDto>, GetProductByIdQueryHandler>();
// ...etc

5. Use It!

Inject IDispatcher wherever you need it (like your API controllers).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
[ApiController]
[Route("[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IDispatcher _dispatcher;

    public ProductsController(IDispatcher dispatcher)
    {
        _dispatcher = dispatcher;
    }

    [HttpPost]
    public async Task<IActionResult> Create([FromBody] CreateProductCommand command)
    {
        await _dispatcher.SendAsync(command);
        // Consider returning a 201 Created with location header
        return Ok();
    }

    [HttpGet("{id}")]
    public async Task<ActionResult<ProductDto>> Get(Guid id)
    {
        var query = new GetProductByIdQuery(id);
        var product = await _dispatcher.QueryAsync<GetProductByIdQuery, ProductDto>(query);
        return product != null ? Ok(product) : NotFound();
    }
}

Why Bother? The Payoff

  • No Extra Dependency: One less library to worry about, manage, or pay for.
  • Crystal Clear Flow: You know exactly how a command/query gets to its handler. Debugging is just stepping through your own code. F12 (Go To Definition) on SendAsync or QueryAsync takes you straight to your dispatcher, not library internals.
  • Total Control: Want logging, validation, or transactions around your handlers? Implement it yourself using decorators or simple middleware logic in your dispatcher. No need to learn a library-specific pipeline model.
  • Simplicity: It keeps the focus on the CQRS pattern itself, not on the tooling.

What About MediatR’s Bells and Whistles?

Now, let’s be clear: MediatR offers more than just simple dispatching, and its other features are often where its perceived value lies, especially for complex applications. It’s worth acknowledging these, particularly the point often raised about handling cross-cutting concerns:

  • Pipelines (Behaviors): This is arguably MediatR’s killer feature for many. It provides a clean, built-in mechanism (IPipelineBehavior<TRequest, TResponse>) to implement cross-cutting concerns – think logging, validation, transaction management, security checks, caching – that need to wrap around your command and query handlers. It’s a structured way to add logic before or after the actual handler runs.

    • The DIY Alternative: As mentioned, you can replicate this using the Decorator pattern combined with your Dependency Injection container. You’d create specific decorator classes (e.g., ValidationCommandHandlerDecorator<TCommand>, TransactionCommandHandlerDecorator<TCommand>) and configure your DI setup to apply them in the desired order, effectively building your own pipeline.
    • The Trade-Off: While the decorator approach gives you maximum transparency and control (you own the code, no library ‘magic’), let’s be honest: setting up and managing a robust, ordered pipeline with decorators manually requires significantly more upfront configuration and boilerplate code compared to leveraging MediatR’s pipeline feature. The convenience MediatR offers here is undeniable and a major reason teams adopt it. It’s a crucial point to weigh against the desire for fewer dependencies and ultimate explicitness.
  • Notifications: MediatR provides a publish/subscribe mechanism for “fire-and-forget” events where multiple independent handlers might need to react (e.g., OrderCreated event triggering email sending, inventory update, etc.).

    • The DIY Alternative: For this, you can often implement a simpler observer pattern yourself. Create a dedicated IEventDispatcher (or similar) that resolves IEnumerable<IEventHandler<TEvent>> from your DI container and invokes all registered handlers. For more complex scenarios needing persistence, outbox patterns, or distributed messaging, integrating dedicated libraries like MassTransit or Rebus is often a better, more focused approach anyway, rather than relying solely on MediatR’s in-process notifications. Separating eventing from command/query dispatch can often lead to clearer boundaries.

The key takeaway here isn’t necessarily that these features aren’t valuable, but whether the convenience they offer outweighs the potential complexity, dependency management, and now licensing considerations introduced by the library, especially if your primary need is just the core command/query dispatch. If you heavily rely on and benefit from the pipeline behaviors, MediatR might still be the right tool for you, but it’s worth understanding the alternatives and the trade-offs involved.

Wrapping Up

Look, MediatR has been useful for many, but this licensing change is a great nudge to question our dependencies. For the core job of dispatching commands and queries in a CQRS setup, a simple, home-rolled approach using standard .NET features is often more than enough.

It gives you back control, simplifies your stack, and avoids potential future costs or licensing headaches. Before automatically reaching for MediatR (or any library), ask yourself: could a few interfaces and a simple dispatcher class do the job? You might be surprised how often the answer is yes.


Disclaimer: The views expressed in this post are my own, based on my experiences and observations. Technology choices are often context-dependent, and I welcome different perspectives or challenges to my points in the comments below!


Further Reading

Built with Hugo
Theme Stack designed by Jimmy