If you’ve worked with modern ASP.NET Core, you’ve almost certainly encountered MediatR. It’s a fantastic library, and for good reason; it has become the de-facto standard for implementing the CQRS pattern in .NET, helping you build clean, decoupled, and maintainable applications.

But have you ever paused to look behind the curtain? In my experience building several lean microservices, I’ve found that while MediatR is a go-to, there are times when a simpler, handcrafted approach is more effective. This post is born from that experience.

We’re going to explore why you might want to implement CQRS without MediatR, and then we’ll do it step-by-step. We’ll build our own lightweight CQRS dispatcher that gives you full control and a deeper understanding of the pattern.

What is CQRS? A Sharp Refresher

CQRS stands for Command Query Responsibility Segregation. It’s a pattern that separates read logic from write logic.

  • Commands: Represent an intent to change state (e.g., CreateProductCommand). While purists argue they should return void, it’s a pragmatic and common practice for them to return a small identifier, like the ID of the newly created entity, to facilitate subsequent operations.
  • Queries: Represent a request for data (e.g., GetProductByIdQuery). They must not change state.

This separation is the core of implementing CQRS in ASP.NET Core and allows you to optimize the read (query) and write (command) stacks independently.

Step 1: Designing a Lightweight CQRS Dispatcher’s Abstractions

First, we need a set of simple interfaces. These will be the foundation of our entire implementation.

The Request Abstractions

// CQRS/IRequest.cs
namespace YourApp.CQRS;

// Represents a request that returns a response
public interface IRequest<out TResponse> { }

// CQRS/ICommand.cs
public interface ICommand<out TResponse> : IRequest<TResponse> { }

// CQRS/IQuery.cs
public interface IQuery<out TResponse> : IRequest<TResponse> { }

The Handler Abstraction

This is the workhorse. Any class that processes a request will implement this interface.

// CQRS/IRequestHandler.cs
namespace YourApp.CQRS;

public interface IRequestHandler<in TRequest, TResponse> where TRequest : IRequest<TResponse>
{
    Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken);
}

The Dispatcher Abstraction

This is the single entry point our controllers will use to send requests.

// CQRS/IDispatcher.cs
namespace YourApp.CQRS;

public interface IDispatcher
{
    Task<TResponse> SendAsync<TResponse>(IRequest<TResponse> request, CancellationToken cancellationToken = default);
}

Step 2: Implementing the Dispatcher

Our dispatcher will use Dependency Injection to find and execute the correct handler for any given request.

// CQRS/Dispatcher.cs
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Threading;
using System.Threading.Tasks;

namespace YourApp.CQRS;

public class Dispatcher : IDispatcher
{
    private readonly IServiceProvider _serviceProvider;

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

    public async Task<TResponse> SendAsync<TResponse>(IRequest<TResponse> request, CancellationToken cancellationToken = default)
    {
        var handlerType = typeof(IRequestHandler<,>).MakeGenericType(request.GetType(), typeof(TResponse));
        var handler = _serviceProvider.GetRequiredService(handlerType);

        return await (Task<TResponse>)handler.GetType()
            .GetMethod("Handle")!
            .Invoke(handler, new object[] { request, cancellationToken })!;
    }
}

A Note on Reflection and Performance The GetMethod(...).Invoke(...) call uses reflection, which is slower than a direct method call. For most web applications, this overhead is negligible. However, for high-performance scenarios, you could replace the invocation with ((dynamic)handler).Handle((dynamic)request, cancellationToken) for slightly cleaner code, or implement a more advanced solution using cached expression trees to achieve near-native performance. For this guide, we’ve prioritized clarity and simplicity.

Step 3: Wiring It Up in ASP.NET Core without MediatR

Now, we need to register our dispatcher and handlers with the DI container in Program.cs.

You can do this manually or use a helper library like Scrutor. Both methods are shown below.

Option 1: The Easy Way (with Scrutor) First, add the package: dotnet add package Scrutor.

// Program.cs
using YourApp.CQRS;
using System.Reflection;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<IDispatcher, Dispatcher>();

// Use Scrutor to scan the assembly and register all handlers
builder.Services.Scan(selector =>
    selector.FromAssemblies(Assembly.GetExecutingAssembly())
        .AddClasses(filter => filter.AssignableTo(typeof(IRequestHandler<,>)))
        .AsImplementedInterfaces()
        .WithScopedLifetime());

// ... rest of Program.cs

Option 2: The Zero-Dependency Way (Manual Registration) If you prefer to avoid extra packages, you can write a small extension method yourself.

// Program.cs
using YourApp.CQRS;
using System.Reflection;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<IDispatcher, Dispatcher>();

// Manually scan and register handlers
var assembly = Assembly.GetExecutingAssembly();
var handlerTypes = assembly.GetTypes()
    .Where(t => t.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IRequestHandler<,>)) && !t.IsAbstract);

foreach (var type in handlerTypes)
{
    var interfaces = type.GetInterfaces()
        .Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IRequestHandler<,>));
        
    foreach(var interfaceType in interfaces)
    {
        builder.Services.AddScoped(interfaceType, type);
    }
}

// ... rest of Program.cs

Step 4: Putting It All Together: A Practical Example

Let’s see our lightweight CQRS dispatcher in action with a ProductsController.

(The Command, Query, and Handler code is the same as the previous version.)

The API Controller Our controller becomes incredibly clean. It has one dependency, IDispatcher, and its only job is to dispatch requests.

// Controllers/ProductsController.cs
[ApiController]
[Route("[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IDispatcher _dispatcher;

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

    [HttpGet("{id:guid}")]
    public async Task<IActionResult> GetProductById(Guid id, CancellationToken cancellationToken)
    {
        var query = new GetProductByIdQuery(id);
        var product = await _dispatcher.SendAsync(query, cancellationToken);
        return product is not null ? Ok(product) : NotFound();
    }

    [HttpPost]
    public async Task<IActionResult> CreateProduct([FromBody] CreateProductCommand command, CancellationToken cancellationToken)
    {
        var productId = await _dispatcher.SendAsync(command, cancellationToken);
        return CreatedAtAction(nameof(GetProductById), new { id = productId }, productId);
    }
}

The Benefits: Why Bother Implementing CQRS from Scratch?

  • Zero External Dependencies: You are in complete control of the code. No third-party breaking changes, no waiting for library updates.
  • Simplicity and Control: The entire implementation is under 50 lines of code. It’s easy to understand, debug, and extend exactly to your needs.
  • Deeper Understanding: You now know exactly how libraries like MediatR work. The magic is gone, replaced by a solid understanding of DI and reflection.
  • Enforces SOLID Principles: This pattern naturally guides you toward the Single Responsibility Principle. Each handler does one thing, making your code focused and highly testable.

When to Still Use MediatR

MediatR is an excellent, mature library. You should still reach for it when:

  • You need pipeline behaviors: Its killer feature for handling cross-cutting concerns (validation, logging, caching) is powerful for complex applications.
  • You need notifications: Its INotification system provides a robust in-process publish/subscribe mechanism.
  • You’re on a large team: Sticking with a well-known standard can reduce the learning curve for new developers.

Conclusion

Understanding the patterns behind the libraries we use makes us better developers. By building your own lightweight CQRS dispatcher, you’ve created a viable, dependency-free alternative for smaller projects and gained a much deeper appreciation for the mechanics of CQRS in ASP.NET Core. You now have a powerful tool in your architectural toolbox, ready for when the situation calls for simplicity and control.

About the Author

Abhinaw Kumar is a software engineer who builds real-world systems: from resilient ASP.NET Core backends to clean, maintainable Angular frontends. With over 11+ years in production development, he shares what actually works when you're shipping software that has to last.

Read more on the About page or connect on LinkedIn.

Frequently Asked Questions

Is MediatR required for CQRS in .NET?

No. CQRS is an architectural pattern, and MediatR is just one popular tool used to implement it. You can implement the pattern with any tool, or from scratch, as shown here.

When should I avoid rolling my own CQRS dispatcher?

Avoid it in large, complex applications where you’ll benefit from MediatR’s mature feature set like pipeline behaviors and notifications. Also, if your team is already standardized on MediatR, introducing a custom solution can add unnecessary friction.

Related Posts