As enterprise applications grow, domain logic often becomes tightly coupled with data access models, making codebases hard to maintain and scale. CQRS (Command Query Responsibility Segregation) separates read operations (Queries) from write operations (Commands).
Combined with the Mediator pattern using the popular MediatR library, this pattern decouples your API controllers or endpoints from the underlying business logic handlers, resulting in clean, testable, and single-responsibility code blocks.
Step 1: Install Required NuGet Packages
Add MediatR and dependency injection support to your project via the CLI:
Bash
dotnet add package MediatR
dotnet add package Microsoft.Extensions.DependencyInjection.Abstractions
Step 2: Define a Command and Its Handler (Write Operation)
Commands represent actions that mutate state (e.g., creating a record). Implement IRequest<TResponse> for the command and IRequestHandler<TRequest, TResponse> for its processing logic.
C#
using MediatR;
// 1. Command Record representing the intent
public record CreateUserCommand(string Username, string Email) : IRequest<int>;
// 2. Command Handler containing business logic
public class CreateUserCommandHandler : IRequestHandler<CreateUserCommand, int>
{
private readonly IUserRepository _userRepository;
public CreateUserCommandHandler(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task<int> Handle(CreateUserCommand request, CancellationToken cancellationToken)
{
// Execute business validation or domain rule checks here
var userId = await _userRepository.AddUserAsync(request.Username, request.Email);
// Return generated record identifier
return userId;
}
}
Step 3: Define a Query and Its Handler (Read Operation)
Queries represent operations that fetch data without altering state. They optimize read paths independently.
C#
using MediatR;
// 1. Query Record representing the data request
public record GetUserByIdQuery(int Id) : IRequest<UserDto?>;
// 2. Query Response DTO
public record UserDto(int Id, string Username, string Email);
// 3. Query Handler optimized for read performance
public class GetUserByIdQueryHandler : IRequestHandler<GetUserByIdQuery, UserDto?>
{
private readonly IUserRepository _userRepository;
public GetUserByIdQueryHandler(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task<UserDto?> Handle(GetUserByIdQuery request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.Id);
if (user is null) return null;
return new UserDto(user.Id, user.Username, user.Email);
}
}
Step 4: Register MediatR in Program.cs
Configure MediatR in your dependency injection container, pointing it to scan your assembly for handlers.
C#
var builder = WebApplication.CreateBuilder(args);
// Register MediatR handlers from the current assembly
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
// Register your application repository services
builder.Services.AddSingleton<IUserRepository, MockUserRepository>();
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();
Step 5: Dispatch Requests from an API Controller
Inject IMediator into your controller or minimal endpoint handlers to decouple endpoints entirely from business logic implementations.
C#
using MediatR;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
private readonly IMediator _mediator;
public UsersController(IMediator mediator)
{
_mediator = mediator;
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
var query = new GetUserByIdQuery(id);
var result = await _mediator.Send(query);
if (result is null) return NotFound();
return Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateUserCommand command)
{
var userId = await _mediator.Send(command);
return CreatedAtAction(nameof(GetById), new { id = userId }, userId);
}
}
Summary
CQRS separates read and write responsibilities, while MediatR enables communication between application components without direct dependencies. Together, they help organize business logic into focused handlers, improving maintainability, testability, and separation of concerns in ASP.NET Core applications.