Introduction
Command Query Responsibility Segregation (CQRS) is a popular architectural pattern used in modern .NET applications to separate read operations from write operations. By splitting commands and queries into distinct responsibilities, CQRS can improve maintainability, scalability, and code organization.
Many developers implement CQRS using libraries such as MediatR. While MediatR is an excellent tool, it is not a requirement for implementing CQRS. In fact, for many applications, introducing an additional abstraction layer can increase complexity without providing significant benefits.
A lightweight CQRS implementation allows developers to gain the advantages of the pattern while keeping the codebase simple, transparent, and easy to debug.
In this article, you'll learn how CQRS works, why MediatR isn't always necessary, and how to implement CQRS directly in ASP.NET Core applications using built-in dependency injection.
Understanding CQRS
CQRS separates application operations into two categories:
Commands
Commands modify application state.
Examples include:
Creating a user
Updating a product
Deleting an order
Processing a payment
Commands typically do not return data beyond success or failure information.
Queries
Queries retrieve data without modifying application state.
Examples include:
Queries should never change data.
Traditional Approach
Controller
↓
Service
↓
Database
CQRS Approach
Controller
↓
Command Handler
↓
Database
Controller
↓
Query Handler
↓
Database
This separation creates clearer responsibilities and improves maintainability.
Why Developers Use MediatR
MediatR is commonly used to implement CQRS because it provides a mediator pattern that decouples controllers from handlers.
Typical flow:
Controller
↓
Mediator
↓
Handler
↓
Database
Example:
await _mediator.Send(
new CreateProductCommand());
While convenient, this introduces an additional dependency and abstraction layer.
For smaller and medium-sized applications, direct handler invocation can often be simpler.
Implementing Lightweight CQRS
Instead of routing requests through a mediator, controllers can directly depend on command and query handlers.
The architecture becomes:
Controller
↓
Handler
↓
Repository
↓
Database
This reduces indirection while preserving CQRS principles.
Creating a Command
Let's create a command for adding a product.
public class CreateProductCommand
{
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}
The command contains only the data required for the operation.
Creating a Command Handler
Next, implement a dedicated handler.
public class CreateProductHandler
{
private readonly AppDbContext _context;
public CreateProductHandler(
AppDbContext context)
{
_context = context;
}
public async Task<int> HandleAsync(
CreateProductCommand command)
{
var product = new Product
{
Name = command.Name,
Price = command.Price
};
_context.Products.Add(product);
await _context.SaveChangesAsync();
return product.Id;
}
}
The handler contains all business logic related to product creation.
Registering the Handler
Register the handler using ASP.NET Core's built-in dependency injection.
builder.Services.AddScoped<
CreateProductHandler>();
No mediator library is required.
Using the Command Handler
The controller directly invokes the handler.
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
private readonly CreateProductHandler _handler;
public ProductsController(
CreateProductHandler handler)
{
_handler = handler;
}
[HttpPost]
public async Task<IActionResult> Create(
CreateProductCommand command)
{
int id =
await _handler.HandleAsync(command);
return Ok(id);
}
}
The request flow remains simple and easy to follow.
Creating a Query
Queries retrieve data without changing state.
Example:
public class GetProductByIdQuery
{
public int Id { get; set; }
}
Creating a Query Handler
public class GetProductByIdHandler
{
private readonly AppDbContext _context;
public GetProductByIdHandler(
AppDbContext context)
{
_context = context;
}
public async Task<ProductDto?> HandleAsync(
GetProductByIdQuery query)
{
return await _context.Products
.Where(p => p.Id == query.Id)
.Select(p => new ProductDto
{
Id = p.Id,
Name = p.Name,
Price = p.Price
})
.FirstOrDefaultAsync();
}
}
Notice that the query returns a DTO instead of an entity.
This improves API design and prevents unnecessary data exposure.
Using Separate Read Models
One of the advantages of CQRS is the ability to optimize read models independently.
Instead of returning entities:
return await _context.Products
.FirstOrDefaultAsync();
Use specialized DTOs:
public class ProductSummaryDto
{
public int Id { get; set; }
public string Name { get; set; } =
string.Empty;
}
This improves performance and reduces payload size.
Organizing Project Structure
A common folder structure looks like:
Features
│
├── Products
│ ├── Commands
│ │ ├── CreateProductCommand.cs
│ │ └── CreateProductHandler.cs
│ │
│ ├── Queries
│ │ ├── GetProductByIdQuery.cs
│ │ └── GetProductByIdHandler.cs
│ │
│ └── DTOs
│
├── Orders
│
└── Customers
This feature-based organization scales well as applications grow.
Benefits of CQRS Without MediatR
Simpler Debugging
The execution flow is explicit.
Developers can easily trace:
Controller
↓
Handler
↓
Database
No hidden middleware or mediator pipeline exists between components.
Reduced Complexity
Fewer abstractions mean:
Easier onboarding
Simpler maintenance
Less boilerplate code
Fewer Dependencies
The application relies primarily on:
ASP.NET Core
Dependency Injection
Entity Framework Core
This reduces external package management overhead.
Better Performance
Although the performance difference is usually small, direct handler invocation avoids mediator dispatch overhead.
For high-throughput systems, reducing unnecessary layers can be beneficial.
When MediatR May Still Be Useful
Despite the advantages of a lightweight approach, MediatR remains valuable in some scenarios.
Examples include:
Large enterprise systems
Extensive pipeline behaviors
Cross-cutting concerns
Request validation pipelines
Centralized logging
Notification-based workflows
If your application heavily depends on these patterns, MediatR may still be the right choice.
Common Mistakes
Mixing Commands and Queries
Avoid code like:
public async Task<Product> UpdateAndGetProduct()
Commands should modify data.
Queries should retrieve data.
Keep these responsibilities separate.
Business Logic in Controllers
Controllers should remain thin.
Avoid:
[HttpPost]
public async Task<IActionResult> Create()
{
// Business logic here
}
Business rules belong in handlers.
Returning Entities Directly
Prefer DTOs over EF Core entities to improve API stability and security.
Best Practices
When implementing lightweight CQRS:
Keep commands focused on a single action.
Keep queries read-only.
Use dedicated handlers for each operation.
Return DTOs instead of entities.
Organize code by feature rather than technical layer.
Keep controllers thin.
Use dependency injection for handler registration.
Apply validation before executing handlers.
Use asynchronous database operations.
Avoid introducing abstractions that do not provide clear value.
Conclusion
CQRS is a powerful architectural pattern that helps separate read and write responsibilities, leading to cleaner and more maintainable applications. While MediatR is commonly associated with CQRS in the .NET ecosystem, it is not a requirement.
By implementing command and query handlers directly with ASP.NET Core's built-in dependency injection, developers can achieve the benefits of CQRS while keeping their architecture simple and transparent. This lightweight approach reduces dependencies, improves debugging, and minimizes unnecessary complexity.
For many modern .NET applications, especially small and medium-sized systems, CQRS without MediatR offers a practical balance between architectural discipline and development simplicity.