Introduction

For years, MediatR has been the de facto standard for implementing CQRS (Command Query Responsibility Segregation) in .NET applications. It provided a clean mediator abstraction, pipeline behaviors, and a well-understood developer experience.

However, the landscape is evolving.

With recent shifts toward commercial licensing and long-term vendor considerations, many teams—especially in enterprise environments—are reassessing their dependencies. This raises a fundamental question:

Do we really need an external library to implement a pattern we fully understand?

The answer, increasingly, is no.

In this article, we’ll walk through how to implement CQRS without MediatR in .NET 10, using Clean Architecture principles, while maintaining extensibility, performance, and long-term maintainability.

Working source code: https://github.com/rijwanansari/clean-architecture-dotnet10

Why Move Away from MediatR?

Let’s be clear—MediatR is still a solid library. But from an architectural perspective, there are valid concerns:

1. Licensing and Vendor Risk

  • Partial movement toward commercial licensing
  • Potential compliance overhead in enterprise environments
  • Long-term dependency uncertainty

2. Over-Abstraction

  • Hidden execution flow
  • Difficult debugging in complex pipelines

3. Unnecessary Dependency

  • CQRS is a pattern, not a framework
  • Can be implemented in ~100–200 lines of code

Architectural Vision

We aim to build:

  • A lightweight mediator
  • Fully aligned with Clean Architecture
  • With explicit control over:
    • Dispatching
    • Validation
    • Cross-cutting concerns

Clean Architecture Overview

Our structure follows strict dependency rules:

Domain ← Application ← Infrastructure ← WebApi
Responsibilities
LayerResponsibility
DomainBusiness rules, entities, value objects
ApplicationCQRS, handlers, validation
InfrastructureEF Core, repositories
Web APIControllers, middleware

CQRS Fundamentals

CQRS separates operations into two categories:

TypePurposeSide Effects
CommandChange stateYes
QueryRead stateNo

This separation leads to:

  • Better scalability
  • Clearer intent
  • Easier testing

Step-by-Step Implementation of CQRS without MediatR

Step 1: Define Core Contracts

At the heart of CQRS are simple abstractions.

public interface ICommand<TResult> { }
public interface IQuery<TResult> { }
Handlers
public interface ICommandHandler<TCommand, TResult>
    where TCommand : ICommand<TResult>
{
    Task<TResult> HandleAsync(TCommand command, CancellationToken ct);
}

public interface IQueryHandler<TQuery, TResult>
    where TQuery : IQuery<TResult>
{
    Task<TResult> HandleAsync(TQuery query, CancellationToken ct);
}
Void Commands
public readonly struct Unit
{
    public static readonly Unit Value = new();
}

Step 2: Build a Lightweight Dispatcher

Instead of relying on MediatR, we create our own dispatcher.

public interface IDispatcher
{
    Task<TResult> SendAsync<TResult>(ICommand<TResult> command, CancellationToken ct = default);
    Task<TResult> QueryAsync<TResult>(IQuery<TResult> query, CancellationToken ct = default);
}
Implementation
public class Dispatcher : IDispatcher
{
    private readonly IServiceProvider _provider;

    public Dispatcher(IServiceProvider provider)
    {
        _provider = provider;
    }

    public Task<TResult> SendAsync<TResult>(ICommand<TResult> command, CancellationToken ct = default)
    {
        var handlerType = typeof(ICommandHandler<,>)
            .MakeGenericType(command.GetType(), typeof(TResult));

        dynamic handler = _provider.GetRequiredService(handlerType);
        return handler.HandleAsync((dynamic)command, ct);
    }

    public Task<TResult> QueryAsync<TResult>(IQuery<TResult> query, CancellationToken ct = default)
    {
        var handlerType = typeof(IQueryHandler<,>)
            .MakeGenericType(query.GetType(), typeof(TResult));

        dynamic handler = _provider.GetRequiredService(handlerType);
        return handler.HandleAsync((dynamic)query, ct);
    }
}

This dispatcher:

  • Resolves handlers via DI
  • Routes commands/queries dynamically
  • Keeps the system loosely coupled

Step 3: Add Validation Pipeline

A key benefit of MediatR is pipeline behaviors. We replicate that using the Decorator Pattern.

public class ValidatingDispatcher : IDispatcher
{
    private readonly IDispatcher _inner;
    private readonly IServiceProvider _provider;

    public ValidatingDispatcher(IDispatcher inner, IServiceProvider provider)
    {
        _inner = inner;
        _provider = provider;
    }

    public async Task<TResult> SendAsync<TResult>(ICommand<TResult> command, CancellationToken ct)
    {
        var validators = _provider.GetServices<IValidator<ICommand<TResult>>>();

        foreach (var validator in validators)
        {
            await validator.ValidateAndThrowAsync(command, ct);
        }

        return await _inner.SendAsync(command, ct);
    }

    public Task<TResult> QueryAsync<TResult>(IQuery<TResult> query, CancellationToken ct)
        => _inner.QueryAsync(query, ct);
}

Step 4: Implement a Feature (Vertical Slice)

Command
public record CreateProductCommand(string Name, decimal Price) : ICommand<Guid>;
Handler
public class CreateProductHandler : ICommandHandler<CreateProductCommand, Guid>
{
    private readonly IProductRepository _repo;
    private readonly IUnitOfWork _uow;

    public async Task<Guid> HandleAsync(CreateProductCommand cmd, CancellationToken ct)
    {
        var product = Product.Create(cmd.Name, cmd.Price);

        await _repo.AddAsync(product);
        await _uow.SaveChangesAsync(ct);

        return product.Id;
    }
}

Step 5: Dependency Injection

services.AddScoped<Dispatcher>();
services.AddScoped<IDispatcher>(sp =>
    new ValidatingDispatcher(
        sp.GetRequiredService<Dispatcher>(),
        sp));

With assembly scanning, handlers and validators are registered automatically.

Step 6: API Usage

app.MapPost("/products", async (CreateProductCommand cmd, IDispatcher dispatcher) =>
{
    var result = await dispatcher.SendAsync(cmd);
    return Results.Ok(result);
});

Working source code: https://github.com/rijwanansari/clean-architecture-dotnet10

Extending the Pipeline (Enterprise-Ready)

The real power of this approach comes from extensibility.

You can easily introduce:

Logging Behavior
  • Request/response tracing
Transaction Behavior
  • Wrap commands in database transactions
Caching Behavior
  • Cache query results
Performance Monitoring
  • Track execution time

Advantages of This Approach

Full Control

No hidden pipelines. Everything is explicit and debuggable.

No Vendor Lock-In

You own your architecture—no external dependency risks.

Lightweight

Minimal abstraction, maximum clarity.

Highly Extensible

You can build pipelines tailored to your needs.

Trade-offs to Consider

❗ More Responsibility

You must implement pipeline behaviors yourself.

❗ Slightly More Boilerplate

But this is predictable and manageable.

Architect’s Perspective

From a senior engineering standpoint, this approach is not about avoiding a library—it’s about owning your architecture.

CQRS is a pattern. MediatR is an implementation.
As architects, we should not outsource core architectural patterns when they are simple to control ourselves.

Working source code: https://github.com/rijwanansari/clean-architecture-dotnet10

Conclusion

Implementing CQRS without MediatR in .NET 10 is not only feasible—it’s often preferable for enterprise systems.

By combining:

  • Clean Architecture
  • A custom dispatcher
  • Decorator-based pipelines

You achieve a system that is:

  • Transparent
  • Maintainable
  • Future-proof

Final Thought

In modern software architecture, the goal is not to use more libraries—it’s to use fewer, better abstractions.

And sometimes, the best abstraction is the one you design yourself.

If you’re building enterprise-grade systems or aiming for architectural excellence, this approach gives you both control and confidence.