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
| Layer | Responsibility |
|---|---|
| Domain | Business rules, entities, value objects |
| Application | CQRS, handlers, validation |
| Infrastructure | EF Core, repositories |
| Web API | Controllers, middleware |
CQRS Fundamentals
CQRS separates operations into two categories:
| Type | Purpose | Side Effects |
| Command | Change state | Yes |
| Query | Read state | No |
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.
