CQRS (Command Query Responsibility Segregation) is a powerful architectural pattern that helps scale and organize modern applications, especially in the context of Domain-Driven Design (DDD). Combined with MediatR, a popular .NET library for implementing the Mediator pattern, CQRS becomes clean, maintainable, and testable.
In this blog, we'll explore:
- What is CQRS?
- Why use MediatR?
- Implementing CQRS with MediatR in C#
- Folder structure best practices
- Examples: Create, Read, Update
- Validation and error handling
- Dependency injection and testing tips
🔍 What is CQRS?
CQRS stands for Command Query Responsibility Segregation. It separates:
- Commands: write operations that change state (e.g., Create, Update, Delete)
- Queries: read operations that fetch data
This separation allows each side to scale independently, apply different optimizations, and follow different architectural principles (e.g., write = strict validation, read = performance-focused).
🧭 Why MediatR?
MediatR allows decoupling request handling logic using the Mediator pattern:
- Commands and Queries are simple C# objects (POCOs)
- Handlers encapsulate the logic
- No service locator, no static dependencies
dotnet add package MediatR
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection
🏗️ Recommended Folder Structure
✍️ Command Example: Create Product
Step 1 – Define the Command
public record CreateProductCommand(string Name, decimal Price) : IRequest<Guid>;
Step 2 – Create the Handler
public class CreateProductHandler : IRequestHandler<CreateProductCommand, Guid>
{
private readonly IProductRepository _repository;
public CreateProductHandler(IProductRepository repository)
{
_repository = repository;
}
public async Task<Guid> Handle(CreateProductCommand request, CancellationToken cancellationToken)
{
var product = new Product
{
Id = Guid.NewGuid(),
Name = request.Name,
Price = request.Price,
CreatedAt = DateTime.UtcNow
};
await _repository.AddAsync(product);
return product.Id;
}
}
Step 3 – Use It in a Controller
[HttpPost]
public async Task<IActionResult> Create(CreateProductCommand command)
{
var id = await _mediator.Send(command);
return CreatedAtAction(nameof(GetById), new { id }, id);
}
🔎 Query Example: Get Product By ID
Step 1 – Define the Query
public record GetProductByIdQuery(Guid Id) : IRequest<ProductDto>;
Step 2 – Create the Handler
public class GetProductByIdHandler : IRequestHandler<GetProductByIdQuery, ProductDto>
{
private readonly IProductRepository _repository;
public GetProductByIdHandler(IProductRepository repository)
{
_repository = repository;
}
public async Task<ProductDto> Handle(GetProductByIdQuery request, CancellationToken cancellationToken)
{
var product = await _repository.GetByIdAsync(request.Id);
if (product == null)
throw new NotFoundException(nameof(Product), request.Id);
return new ProductDto(product.Id, product.Name, product.Price);
}
}
✅ Best Practices
1. Separation of Concerns
Don't put domain logic in handlers.
Keep handlers thin—delegate to services or aggregates if needed.
2. Validation with FluentValidation
public class CreateProductValidator : AbstractValidator<CreateProductCommand>
{
public CreateProductValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
RuleFor(x => x.Price).GreaterThan(0);
}
}
Register validators and use the pipeline to validate automatically:
services.AddValidatorsFromAssemblyContaining<CreateProductValidator>();
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
ValidationBehavior Example:
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
var context = new ValidationContext<TRequest>(request);
var failures = _validators
.Select(v => v.Validate(context))
.SelectMany(result => result.Errors)
.Where(f => f != null)
.ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
return await next();
}
}
3. Use DTOs – Don’t Expose Entities
Avoid returning domain models directly. Instead, return DTOs like:
public record ProductDto(Guid Id, string Name, decimal Price);
4. Keep Handlers Simple and Focused
Bad:
- Does Validation
- Mapping
- Domain Logic
- Logging
Good:
- Validate (via pipeline)
- Call service/repo
- Return result
5. Unit Testing Handlers
[Fact]
public async Task CreateProduct_Should_Return_Id()
{
// Arrange
var mockRepo = new Mock<IProductRepository>();
var handler = new CreateProductHandler(mockRepo.Object);
var command = new CreateProductCommand("Test", 10);
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
Assert.IsType<Guid>(result);
mockRepo.Verify(r => r.AddAsync(It.IsAny<Product>()), Times.Once);
}
🧠 Wrap-Up
CQRS + MediatR = Powerful, clean, and testable architecture.
It helps you:
- Organize codebase by behavior
- Reduce complexity and duplication
- Facilitate testing and scaling
- Introduce pipelines for validation, logging, and metrics
Start small. Even if your app doesn’t need full CQRS, using MediatR for commands and queries can enforce structure and testability from day one.
🚀 Ready to Try?
You can bootstrap this pattern in any clean architecture app using:
dotnet new webapi -n YourApp
dotnet add package MediatR
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection
dotnet add package FluentValidation
Then start building commands, queries, handlers, and enjoy a maintainable codebase!
Top comments (0)