8 Steps to Implement CQRS in ASP.NET Core Without MediatR: A Complete Clean Architecture Guide
CQRS (Command Query Responsibility Segregation) is an architectural pattern that separates application operations into two different models:
- Commands → Operations that modify data (Create, Update, Delete)
- Queries → Operations that only read data
Many ASP.NET Core developers implement CQRS using libraries like MediatR, but CQRS itself does not require MediatR. MediatR is only a messaging library that helps route commands and queries.
In this article, we will implement CQRS in ASP.NET Core Web API without MediatR using:
- Clean Architecture principles
- Commands
- Queries
- Handlers
- Dependency Injection
- Entity Framework Core
What is CQRS?
Traditional CRUD architecture usually looks like this:
Controller
|
|
Service
|
|
Repository
|
|
Database
The same service handles:
- Creating data
- Updating data
- Reading data
- Searching
- Reporting
As applications grow, services become complex.
Example:
ProductService.cs
CreateProduct()
UpdateProduct()
DeleteProduct()
GetProduct()
SearchProducts()
GetReports()
CQRS separates these responsibilities.
CQRS Architecture
A CQRS-based application looks like:
API Layer
|
-------------------------
| |
Commands Queries
| |
Command Handler Query Handler
| |
Write Database Read Database
Project Structure
A simple CQRS structure:
RetailERP.Api
├── Controllers
RetailERP.Application
├── Products
│
├── Commands
│ └── CreateProduct
│ ├── CreateProductCommand.cs
│ └── CreateProductHandler.cs
│
└── Queries
└── GetProduct
├── GetProductQuery.cs
└── GetProductHandler.cs
RetailERP.Domain
├── Product.cs
RetailERP.Infrastructure
├── ApplicationDbContext.cs
Step 1: Create Domain Entity
Let’s start creating code and see how can we use CORS in ASP.NET Core. Let’s create a Product entity.
public class Product
{
public Guid Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
Step 2: Create Database Context
public class ApplicationDbContext
: DbContext
{
public ApplicationDbContext(
DbContextOptions options)
: base(options)
{
}
public DbSet<Product> Products { get; set; }
}
Implementing Commands
A command represents an action that changes application state.
Example:z
Create Product
Update Product
Delete Product
Step 3: Create CreateProductCommand
public class CreateProductCommand
{
public string Name { get; set; }
public decimal Price { get; set; }
}
This class only contains input data.
It does not contain business logic.
Step 4: Create Command Handler
The handler contains the command execution logic.
public class CreateProductHandler
{
private readonly ApplicationDbContext _context;
public CreateProductHandler(
ApplicationDbContext context)
{
_context = context;
}
public async Task<Guid> Handle(
CreateProductCommand command)
{
var product = new Product
{
Id = Guid.NewGuid(),
Name = command.Name,
Price = command.Price
};
_context.Products.Add(product);
await _context.SaveChangesAsync();
return product.Id;
}
}
Implementing Queries
Queries only retrieve data.
Examples:
Get Product By Id
Get All Products
Search Products
Step 5: Create Query
public class GetProductQuery
{
public Guid Id { get; set; }
}
Step 6: Create Query Handler
public class GetProductHandler
{
private readonly ApplicationDbContext _context;
public GetProductHandler(
ApplicationDbContext context)
{
_context = context;
}
public async Task<Product?> Handle(
GetProductQuery query)
{
return await _context.Products
.FirstOrDefaultAsync(
x => x.Id == query.Id);
}
}
Step 7: Register Handlers With Dependency Injection
Since we are not using MediatR, we register handlers manually.
In Program.cs:
builder.Services.AddScoped<
CreateProductHandler>();
builder.Services.AddScoped<
GetProductHandler>();
Step 8: Create API Controller
Now the controller communicates with handlers.
[ApiController]
[Route("api/products")]
public class ProductsController
: ControllerBase
{
private readonly CreateProductHandler
_createHandler;
private readonly GetProductHandler
_getHandler;
public ProductsController(
CreateProductHandler createHandler,
GetProductHandler getHandler)
{
_createHandler = createHandler;
_getHandler = getHandler;
}
[HttpPost]
public async Task<IActionResult> Create(
CreateProductCommand command)
{
var id = await _createHandler
.Handle(command);
return Ok(id);
}
[HttpGet("{id}")]
public async Task<IActionResult> Get(
Guid id)
{
var product =
await _getHandler.Handle(
new GetProductQuery
{
Id=id
});
return Ok(product);
}
}
The Complete Flow
Creating Product
Request:
POST /api/products
Body:
{
"name":"Laptop",
"price":1500
}
Flow:
Controller
|
CreateProductCommand
|
CreateProductHandler
|
Database
Getting Product
Request:
GET /api/products/{id}
Flow:
Controller
|
GetProductQuery
|
GetProductHandler
|
Database
Adding Generic Dispatcher (MediatR Alternative)
In larger projects, manually injecting every handler becomes repetitive.
We can create our own dispatcher.
ICommand Interface
public interface ICommand<TResponse>
{
}
ICommandHandler Interface
public interface ICommandHandler<TCommand,TResponse>
{
Task<TResponse> Handle(
TCommand command);
}
Example Command
public class CreateProductCommand
: ICommand<Guid>
{
public string Name {get;set;}
public decimal Price {get;set;}
}
Handler
public class CreateProductHandler :
ICommandHandler<CreateProductCommand,Guid>
{
public async Task<Guid> Handle(
CreateProductCommand command)
{
// business logic
}
}
Dispatcher
public interface ICommandDispatcher
{
Task<T> Send<T>(
object command);
}
Implementation:
public class CommandDispatcher
: ICommandDispatcher
{
private readonly IServiceProvider _provider;
public CommandDispatcher(
IServiceProvider provider)
{
_provider=provider;
}
public async Task<T> Send<T>(
object command)
{
var handlerType =
typeof(ICommandHandler<,>)
.MakeGenericType(
command.GetType(),
typeof(T));
dynamic handler =
_provider.GetRequiredService(
handlerType);
return await handler.Handle(
(dynamic)command);
}
}
Register:
builder.Services
.AddScoped<ICommandDispatcher,
CommandDispatcher>();
Now controllers become cleaner:
[HttpPost]
public async Task<IActionResult> Create(
CreateProductCommand command)
{
var id =
await _dispatcher.Send<Guid>(
command);
return Ok(id);
}
CQRS Without MediatR vs With MediatR
| Feature | Without MediatR | With MediatR |
|---|---|---|
| Dependency | None | NuGet Package |
| Control | Full | Less |
| Code | More | Less |
| Learning | Better understanding | Easier |
| Performance | Slightly better | Good |
| Pipeline Behaviors | Manual | Built-in |
When Should You Avoid MediatR?
Avoid MediatR when:
- Application is small
- You want fewer dependencies
- You prefer explicit code
- You want full control
Example:
Small ERP
Internal API
Startup MVP
When Should You Use MediatR?
Use MediatR when:
- Many commands and queries exist
- You need pipeline behaviors
- You need logging
- You need validation pipelines
Example:
Large SaaS Platform
Enterprise ERP
Banking System
CQRS With DDD
For a DDD-based application:
API
|
Application Layer
|
Commands
Queries
Handlers
|
Domain
Entities
Aggregates
Domain Events
|
Infrastructure
EF Core
Messaging
Database
CQRS fits naturally because:
- Commands protect domain rules
- Queries optimize reads
- Handlers isolate use cases
Conclusion
CQRS does not require MediatR. MediatR only provides a convenient way to implement the mediator pattern.
A clean CQRS implementation can be built using:
- Commands
- Queries
- Handlers
- Dependency Injection
- Interfaces
For modern ASP.NET Core applications, especially ERP systems, SaaS platforms, and DDD-based applications, implementing CQRS manually first helps developers understand the architecture before introducing libraries like MediatR.


