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:
Getting product details
Listing customers
Viewing order history
Retrieving dashboard metrics
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);
}
}

Join the conversation! Your thoughts help the community grow.