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:
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.
Traditional Architecture vs CQRS
| Feature | Traditional CRUD | CQRS |
|---|
| Read/Write Separation | No | Yes |
| Handler Organization | Shared services | Separate handlers |
| Testability | Moderate | Excellent |
| Scalability | Good | Excellent |
| Read Optimization | Limited | Independent |
| Complex Business Logic | Harder to manage | Easier to organize |
CQRS introduces additional structure, making it more suitable for applications with growing complexity.
Implementation Methodology
The research brief focuses on production architecture rather than benchmark data. When evaluating a CQRS implementation:
Test Environment
Keep these variables consistent:
.NET SDK version
Database
Dataset size
Build configuration
Hardware
Test Scenarios
Evaluate:
Metrics to Measure
Collect:
Request latency
Throughput
Memory allocations
Database queries
Handler execution time
CPU utilization
Useful Tools
Useful tools include:
BenchmarkDotNet
dotnet-counters
dotnet-trace
SQL Server Query Store
Application Insights
MiniProfiler
Measure maintainability and code organization alongside runtime performance.
Best Practices
Keep each handler focused on a single responsibility.
Use commands only for state changes.
Use queries only for data retrieval.
Keep controllers thin.
Use pipeline behaviors for cross-cutting concerns.
Apply AsNoTracking() to read-only queries.
Organize handlers by feature rather than type.
Unit test handlers independently.
Common Mistakes
| Mistake | Impact |
|---|
| Putting business logic back into controllers | Defeats CQRS benefits |
| Using one handler for multiple operations | Reduced maintainability |
| Mixing commands and queries | Blurred responsibilities |
| Ignoring validation | Invalid data reaches handlers |
| Creating CQRS for very small applications | Unnecessary complexity |
| Treating MediatR as a service locator | Poor architecture |
Troubleshooting
Handler Is Not Invoked
Verify:
Multiple Handlers Found
Ensure only one handler exists for each command or query type.
Poor Performance
Review:
Database queries
Handler logic
Pipeline behaviors
Logging overhead
In most cases, performance issues stem from inefficient data access rather than MediatR itself.
FAQs
What is CQRS?
CQRS is an architectural pattern that separates commands (write operations) from queries (read operations) to improve maintainability and scalability.
Is MediatR required for CQRS?
No. CQRS can be implemented without MediatR, but MediatR simplifies request routing and reduces coupling between controllers and business logic.
Should every project use CQRS?
No. Small CRUD applications often do not benefit from the additional abstraction. CQRS is most valuable when business logic becomes complex or read and write workloads differ significantly.
Can CQRS use the same database?
Yes. Many applications begin with a single database for both commands and queries before evolving to separate read models if needed.
Does CQRS improve performance?
Not automatically. Its primary benefit is architectural clarity. Performance improvements come from independently optimizing read and write paths where appropriate.
Conclusion
CQRS, combined with MediatR, provides a clean and maintainable approach to organizing business logic in ASP.NET Core applications. By separating commands from queries, keeping controllers lightweight, and encapsulating each operation in dedicated handlers, developers can build applications that are easier to test, extend, and maintain.
While CQRS introduces additional structure, it becomes increasingly valuable as applications grow in complexity. Applied thoughtfully and supported by proper validation, logging, and monitoring, it forms a solid foundation for scalable, production-ready .NET applications.