Why change the traditional way of coding?
Six months ago, I joined a relatively large logistics Web API project. The team had 5 developers, and everyone was loyal to the traditional 3-tier architecture, often implementing Repository Pattern to manage data access. However, after just 3 months, the Service files began turning into “skyscrapers” with over 2,000 lines of code.
Every time I needed to fix a small piece of logic in the order update section, I had to “swim” through dozens of intertwined functions. This was extremely risky because it was easy to cause a chain reaction of errors in other features. That’s when I proposed applying CQRS (Command Query Responsibility Segregation) combined with the MediatR library.
The results were impressive. After 6 months of operation, the team’s debugging time decreased by about 30%, and writing Unit Tests became much easier. If you’re tired of bloated Controllers or Services, this article is the answer for you, helping you build production-ready systems.
5-Minute Quick Start
Instead of just talking about theory, let’s dive into the implementation to see how it works in practice.
Step 1: Install the library
Open the Terminal in your ASP.NET Core project and run the following command to add the MediatR package:
dotnet add package MediatR
Step 2: Create a Command and Handler
Imagine you need to create a new product. Instead of cramming it into a Service, we will create a separate CreateProductCommand class:
public record CreateProductCommand(string Name, decimal Price) : IRequest<int>;
public class CreateProductHandler : IRequestHandler<CreateProductCommand, int>
{
public async Task<int> Handle(CreateProductCommand request, CancellationToken cancellationToken)
{
// Simulate saving to the Database and returning the product ID
Console.WriteLine($"Creating product: {request.Name}");
return await Task.FromResult(new Random().Next(1, 1000));
}
}
Step 3: Register MediatR
In the Program.cs file, you only need one line of code for MediatR to automatically find the Handlers:
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
Step 4: Call from Controller
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
private readonly IMediator _mediator;
public ProductsController(IMediator mediator) => _mediator = mediator;
[HttpPost]
public async Task<IActionResult> Create(CreateProductCommand command)
{
var id = await _mediator.Send(command);
return Ok(id);
}
}
The Essence of CQRS and MediatR
1. CQRS – Divide and Conquer
CQRS isn’t a magical framework; it’s simply an architectural mindset, similar in philosophy to Hexagonal Architecture. Instead of using a single Model for both reading and writing, we split them into two separate paths:
- Commands: Actions that change data (Create, Update, Delete). They usually only return an ID or a success status.
- Queries: Actions that retrieve data (Get Details, List). These functions must never modify anything in the Database.
2. MediatR – The “Transporter” in your application
MediatR acts as a mediator (In-process Messaging). The Controller no longer needs to know which Service will handle the logic. It just sends a Request, and MediatR automatically finds the correct “address” (Handler) to execute it. This approach prevents the Controller’s constructor from bloating due to injecting too many Services.
Advanced: Centralized Validation and Logging
In real projects, I never write manual Validation code inside the Handler. MediatR provides a powerful feature called Pipeline Behavior, which works similarly to Middleware but specifically for the Application layer.
For example, to measure the performance of every Request, you can create a Logging Behavior like this:
public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
var timer = Stopwatch.StartNew();
var response = await next();
timer.Stop();
if (timer.ElapsedMilliseconds > 500)
{
Console.WriteLine($"Warning: Request {typeof(TRequest).Name} is slow: {timer.ElapsedMilliseconds}ms");
}
return response;
}
}
“Battle-tested” lessons after 6 months of implementation
Applying new technology always comes with practical lessons. Here are some notes to help you avoid common pitfalls:
1. Don’t use a sledgehammer to crack a nut
If you’re just building a simple CRUD website with a few data tables, separating Commands and Queries is a waste of time. CQRS truly shines when business logic becomes complex and requires long-term scalability in a Microservices Architecture.
2. Organize folders by Feature (Vertical Slice)
Instead of the traditional Controllers/ and Models/ structure, try organizing by Features. Everything related to a feature, like CreateProduct.cs (including both the Command and Handler), should be in one place. This helps you find code much faster.
3. Optimize Query performance
A major advantage of CQRS is that you can use Entity Framework on the Command side for transaction management, while using Dapper on the Query side for speed optimization. In my project, this combination reduced listing API latency by up to 40%.
4. Absolutely avoid Handlers calling Handlers
This is the most common mistake. Never use _mediator.Send() inside another Handler to reuse logic. If you need to share code, extract it into a Domain Service or an independent Helper class.
Conclusion
CQRS and MediatR aren’t “silver bullets” that solve every problem, but they are excellent tools for keeping code organized. Separating responsibilities makes the team more confident when upgrading the system and helps new members grasp the project faster.
If you are building a multi-tenant SaaS or a Web API system of moderate complexity or higher, give it a try. The difference in code cleanliness after a few months of operation will surely satisfy you.

