As applications grow, a single model for handling both reads and writes can become difficult to maintain. Business logic becomes tightly coupled, controllers become bloated, and performance optimizations for read-heavy workloads become challenging.
Command Query Responsibility Segregation (CQRS) addresses these issues by separating operations that modify data (Commands) from operations that retrieve data (Queries). Combined with MediatR, CQRS helps organize application logic into small, focused handlers that are easier to test, maintain, and extend.
In this article, you'll build a production-ready CQRS architecture using MediatR in ASP.NET Core 11, understand when to use it, and learn best practices for implementing it effectively.
Note: This article focuses on architectural implementation and testing methodology. Performance improvements depend on application complexity, workload, and infrastructure.
What Is CQRS?
CQRS separates write operations from read operations.
Client
│
▼
Controller
│
▼
MediatR
┌────┴─────┐
▼ ▼
Command Query
Handler Handler
│ │
▼ ▼
Database Database
Commands change application state.
Queries retrieve data without modifying it.
Benefits of CQRS
Using CQRS provides several advantages:
Separation of responsibilities
Smaller, focused classes
Easier unit testing
Improved maintainability
Independent optimization for reads and writes
Cleaner business logic
CQRS is particularly useful for medium to large applications with complex business workflows.
Create the Project
dotnet new webapi -n CqrsDemo
Install MediatR.
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection
Register MediatR
Configure MediatR during application startup.
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(
typeof(Program).Assembly);
});
MediatR automatically discovers command and query handlers within the specified assembly.
Create the Product Entity
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = "";
public decimal Price { get; set; }
}
Create a Command
Commands represent operations that modify data.
using MediatR;
public record CreateProductCommand(
string Name,
decimal Price) : IRequest<int>;
The command returns the ID of the created product.
Create the Command Handler
public class CreateProductHandler
: IRequestHandler<CreateProductCommand, int>
{
private readonly AppDbContext _context;
public CreateProductHandler(
AppDbContext context)
{
_context = context;
}
public async Task<int> Handle(
CreateProductCommand request,
CancellationToken cancellationToken)
{
var product = new Product
{
Name = request.Name,
Price = request.Price
};
_context.Products.Add(product);
await _context.SaveChangesAsync(
cancellationToken);
return product.Id;
}
}
Each handler focuses on a single business operation.
Create a Query
Queries retrieve data without changing application state.
public record GetProductQuery(int Id)
: IRequest<Product?>;
Create the Query Handler
public class GetProductHandler
: IRequestHandler<GetProductQuery, Product?>
{
private readonly AppDbContext _context;
public GetProductHandler(
AppDbContext context)
{
_context = context;
}
public async Task<Product?> Handle(
GetProductQuery request,
CancellationToken cancellationToken)
{
return await _context.Products
.AsNoTracking()
.FirstOrDefaultAsync(
p => p.Id == request.Id,
cancellationToken);
}
}
Using AsNoTracking() improves performance for read-only operations.
Use MediatR in a Controller
Inject IMediator.
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
private readonly IMediator _mediator;
public ProductsController(IMediator mediator)
{
_mediator = mediator;
}
[HttpPost]
public async Task<int> Create(
CreateProductCommand command)
{
return await _mediator.Send(command);
}
[HttpGet("{id}")]
public async Task<Product?> Get(int id)
{
return await _mediator.Send(
new GetProductQuery(id));
}
}
The controller delegates business logic to MediatR, keeping it thin and focused.
Add Validation with Pipeline Behaviors
Cross-cutting concerns can be handled using pipeline behaviors.
public class LoggingBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
Console.WriteLine(typeof(TRequest).Name);
return await next();
}
}
Pipeline behaviors can be used for:
Logging
Validation
Performance monitoring
Authorization
Auditing
This keeps handlers focused on business logic.
End-to-End Request Flow
A typical request follows these steps:
Client sends a request.
Controller receives the request.
Controller sends a command or query to MediatR.
MediatR locates the appropriate handler.
Handler executes business logic.
Database operation completes.
Response is returned to the client.
This separation simplifies testing and maintenance.
Join the conversation! Your thoughts help the community grow.