Hi there! If you’ve been building ASP.NET Core applications for a while, like I have, you probably reached a point where you started asking yourself: “Is this controller getting too fat? Why do I have so much logic here?” I had that exact question in one of our internal payroll processing projects where we were calculating salaries, deductions, and generating pay slips. That’s when I stumbled upon CQRS and later added MediatR to the mix and it really made our code cleaner, modular, and more testable.
In this post, I’ll walk you through:
- What CQRS is
- Why MediatR helps
- How I implemented it in a real project
- Sample code snippets to get you started
Let’s jump in.
What is CQRS?
CQRS stands for Command and Query Responsibility Segregation. Simply put, it means:
- Commands = Operations that change data (e.g., Create, Update, Delete)
- Queries = Operations that read data (e.g., Get Employee Details, Generate Payroll Report)
Instead of having one service doing both reading and writing, we split it.
This idea helped me during one of our internal systems where calculating payroll was mixed in the same service that also fetched employee data. It was hard to manage and painful to test. CQRS helped make that cleaner.
When to Use CQRS
- You have complex business logic
- You want a clear separation between read and write models
- You want to write clean, maintainable code
Don’t worry, you don’t have to go full event sourcing to benefit from CQRS.
Enter MediatR
Now, handling CQRS manually means writing a lot of boilerplate. That’s where MediatR comes in.
MediatR is a simple in-process messaging library. Think of it like this:
You send a request –> It finds the right handler –> Executes logic –> Returns a response
It supports:
- Command and query handlers
- Pipeline behaviors (for validation, logging, etc.)
- Notification handlers (think pub-sub)
Query and Command
In this section, I’ll show you one example of each: a command that generates payroll, and a query that retrieves employee data. This side-by-side view helped me a lot when I was learning CQRS, so I hope it gives you clarity too.
Command
Below is a complete example of a command used to generate payroll for an employee. It includes the command definition, the handler that processes it using a payroll service, and the result model returned to the client. This sample shows how business logic stays cleanly separated from the controller using CQRS with MediatR.
GeneratePayrollCommand.cs
public record GeneratePayrollCommand(
Guid EmployeeId,
decimal BasicSalary,
decimal Tax,
decimal Allowance
) : IRequest<PayrollResult>;
GeneratePayrollHandler.cs
public class GeneratePayrollHandler : IRequestHandler<GeneratePayrollCommand, PayrollResult>
{
private readonly IPayrollService _payrollService;
public GeneratePayrollHandler(IPayrollService payrollService)
{
_payrollService = payrollService;
}
public async Task<PayrollResult> Handle(GeneratePayrollCommand request, CancellationToken cancellationToken)
{
return await _payrollService.CalculateAsync(
request.EmployeeId,
request.BasicSalary,
request.Tax,
request.Allowance
);
}
}
PayrollResult.cs
public class PayrollResult
{
public Guid EmployeeId { get; set; }
public decimal BasicSalary { get; set; }
public decimal Allowance { get; set; }
public decimal Tax { get; set; }
public decimal GrossPay { get; set; }
public decimal NetPay { get; set; }
public DateTime GeneratedAt { get; set; }
}
Query
The following is a full example of a query used to retrieve employee details by their ID. It includes the query definition, the handler that fetches the data from a repository, and the DTO returned to the caller. This sample demonstrates how read operations are isolated and streamlined using MediatR in a CQRS-based setup.
GetEmployeeByIdQuery.cs
public record GetEmployeeByIdQuery(Guid Id) : IRequ
GetEmployeeByIdHandler.cs
public class GetEmployeeByIdHandler : IRequestHandler<GetEmployeeByIdQuery, EmployeeDto>
{
private readonly IEmployeeRepository _repository;
public GetEmployeeByIdHandler(IEmployeeRepository repository)
{
_repository = repository;
}
public async Task<EmployeeDto> Handle(GetEmployeeByIdQuery request, CancellationToken cancellationToken)
{
var employee = await _repository.GetByIdAsync(request.Id);
if (employee == null) return null;
return new EmployeeDto
{
Id = employee.Id,
Name = employee.Name,
Position = employee.Position
};
}
}
EmployeeDto.cs
public class EmployeeDto
{
public Guid Id { get; set; }
public string Name { get; set; }
public string Position { get; set; }
}
Common Mistakes in Using CQRS
In this section, I want to share how to properly set up MediatR in ASP.NET Core but more importantly, I’ll point out some common mistakes I’ve seen from other developers (again, just based on my own personal observations in past projects).
Sometimes, developers adopt CQRS with MediatR but still fall into old habits that break the principle of clean separation. For example, I’ve seen cases where MediatR is injected properly, but services are still called directly from controllers, or worse — the business logic is hardcoded entirely inside the handler.
One common pattern I’ve observed is developers treating the handler as the place for everything, validation, computation, repository access, and even conditional branching. It ends up becoming a fat handler, which is just the modern version of a God class. This not only makes the code harder to test and reuse but also defeats the purpose of using CQRS in the first place.
Here’s a healthier mental model:
- Use MediatR as an orchestrator, its job is to coordinate what should happen (e.g., which command/query to invoke)
- Use service classes to encapsulate business rules, these belong in your domain layer, not your handler
This separation keeps your code modular, reusable, and easier to test. CQRS isn’t a replacement for layering, it’s a tool to help enforce it more clearly.
Let’s go through it step by step, this is how I personally separate the handler from the business rule logic by keeping the orchestration in the handler and the core computations inside a service class.
1. Install the Package
Install-Package MediatR.Extensions.Microsoft.DependencyInjection
This NuGet package registers MediatR into ASP.NET Core’s built-in Dependency Injection system. It’s the official extension from MediatR for easy integration with .NET Core projects, allowing handlers to be resolved automatically at runtime.
2. Configure MediatR in Program.cs
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
This line tells MediatR to scan your assembly (in this case, the one containing Program.cs) for any classes implementing IRequestHandler. Without this, MediatR won’t know how to locate your handlers.
Think of this as the glue between your requests (commands/queries) and their handlers.
3. Create a Command
public record GeneratePayrollCommand(Guid EmployeeId, decimal BasicSalary, decimal Tax, decimal Allowance) : IRequest<PayrollResult>;
This is a “command”, it represents an action your system performs. It’s immutable and self-descriptive. The : IRequest interface from MediatR declares the expected result when this command is sent.
4. Create a Handler
public class GeneratePayrollHandler : IRequestHandler<GeneratePayrollCommand, PayrollResult>
{
private readonly IPayrollService _payrollService;
public GeneratePayrollHandler(IPayrollService payrollService)
{
_payrollService = payrollService;
}
public async Task<PayrollResult> Handle(GeneratePayrollCommand request, CancellationToken cancellationToken)
{
return await _payrollService.CalculateAsync(request.EmployeeId, request.BasicSalary, request.Tax, request.Allowance);
}
}
5. Create the Payroll Service
public interface IPayrollService
{
Task<PayrollResult> CalculateAsync(Guid employeeId, decimal basicSalary, decimal tax, decimal allowance);
}
public class PayrollService : IPayrollService
{
public async Task<PayrollResult> CalculateAsync(Guid employeeId, decimal basicSalary, decimal tax, decimal allowance)
{
// Simulate some delay as if querying from DB or external service
await Task.Delay(100);
var gross = basicSalary + allowance;
var net = gross - tax;
return new PayrollResult
{
EmployeeId = employeeId,
BasicSalary = basicSalary,
Allowance = allowance,
Tax = tax,
GrossPay = gross,
NetPay = net,
GeneratedAt = DateTime.UtcNow
};
}
}
This class contains the logic to handle the command, in this case, to calculate payroll. MediatR automatically wires this handler to the GeneratePayrollCommand based on the generic parameters.
Handlers are great because they isolate business flow logic from the controllers, improving testability and code clarity.
6. Define the Result DTO
public class PayrollResult
{
public Guid EmployeeId { get; set; }
public decimal BasicSalary { get; set; }
public decimal Allowance { get; set; }
public decimal Tax { get; set; }
public decimal GrossPay { get; set; }
public decimal NetPay { get; set; }
public DateTime GeneratedAt { get; set; }
}
7. Controller Usage
[HttpPost("generate")]
public async Task<IActionResult> GeneratePayroll(GeneratePayrollCommand command)
{
var result = await _mediator.Send(command);
return Ok(result);
}
Why Not Just Move Payroll Logic Inside the Handler?
This is a common question and a good one.Technically, you can put the logic directly inside the handler. But I prefer keeping the business logic in a service class. Here’s why:
- Separation of Concerns: The handler’s job is to orchestrate. If we put all payroll logic inside it, the class becomes responsible for both coordination and computation, violating the Single Responsibility Principle.
- Reusability: We might need the payroll computation in other places (like a scheduled job or report generator). With a service class, it’s reusable. If it’s only in the handler, it’s trapped.
- Easier Testing: You can mock the PayrollService in the handler tests and focus only on orchestration. Separately, you can write unit tests just for the business logic in the service.
- Clean Architecture Alignment: Keeping business logic in a service keeps your domain logic decoupled from infrastructure like MediatR. This makes your system more modular and future-proof.
Benefits I Experienced
Here’s what I personally gained by applying CQRS with MediatR:
- Cleaner Controllers
- Easy Unit Testing per handler
- Easier to onboard new developers
- Better separation of concerns
- Scalability: queries and commands can evolve separately
During development, we even layered in FluentValidation using pipeline behaviors to validate commands before they hit the handler.
When NOT to Use It
CQRS isn’t a silver bullet. I would NOT use CQRS with MediatR when:
- The app is very simple (like a basic CRUD payroll admin tool)
- You don’t have the time to train your team
- You’re building a quick MVP or throwaway project
Final Thoughts
If you’re like me, working on modular, mid-to-large apps with long-term maintenance in mind, CQRS with MediatR is gold. It’s not hard to implement, and the structure it gives you is worth every line of setup.
I hope this post helped clarify how you can start using CQRS and MediatR in your ASP.NET Core projects.
Sources & Inspiration
- MediatR GitHub: https://github.com/jbogard/MediatR
- CQRS Summary: https://martinfowler.com/bliki/CQRS.html
- FluentValidation: https://docs.fluentvalidation.net/en/latest