Introduction
As applications evolve from simple CRUD systems into large, business-critical platforms, maintaining clean architecture becomes increasingly challenging. Over time, developers often face bloated controllers, tightly coupled services, and domain models that try to handle both read and write operations. This complexity makes systems difficult to maintain, test, and scale.
CQRS (Command Query Responsibility Segregation), combined with MediatR, offers a structured and scalable solution to these problems. By separating read and write responsibilities and introducing a mediator to decouple components, CQRS enables cleaner code, better separation of concerns, and improved long-term maintainability.
In this article, we’ll explore:
What CQRS is and why it matters
When CQRS should be used
How MediatR supports CQRS
A step-by-step implementation in ASP.NET Core Web API
Best practices and architectural trade-offs
What Is CQRS?
CQRS stands for Command Query Responsibility Segregation.
It is a design pattern that separates read operations (queries) from write operations (commands).
Core Principle
Commands change application state (Create, Update, Delete)
Queries retrieve data and never modify state
In traditional CRUD applications, the same model is often used for both reading and writing. While this works for small systems, it quickly becomes problematic as complexity grows.
Why CQRS Exists
Using a single model for both reads and writes often leads to:
CQRS allows each side to evolve independently, improving clarity, security, and scalability.
When Should You Use CQRS?
CQRS is not a silver bullet and should not be used everywhere.
CQRS Is a Good Fit When:
Business logic is complex
Read and write workloads differ significantly
The system requires strong validation and workflows
Multiple teams work on the same domain
Long-term scalability is important
Avoid CQRS When:
The application is small or short-lived
CRUD operations are straightforward
Simplicity is more important than flexibility
Good rule: If your application is simple today but guaranteed to grow, CQRS is worth considering.
What Is MediatR?
MediatR is a lightweight .NET library that implements the Mediator pattern.
Instead of components calling each other directly, they communicate through a mediator. This eliminates tight coupling and improves testability.
Benefits of MediatR
Decouples controllers from business logic
Encourages single-responsibility handlers
Simplifies cross-cutting concerns (logging, validation)
Improves maintainability and readability
MediatR works in-process, making it ideal for clean application architectures.
CQRS and MediatR Together
CQRS defines what operations exist (commands and queries).
MediatR defines how those operations are dispatched and handled.
| CQRS Concept | MediatR Component |
|---|
| Command | IRequest |
| Query | IRequest<TResponse> |
| Command Handler | IRequestHandler |
| Query Handler | IRequestHandler |
| Domain Event | INotification |
| Cross-cutting logic | IPipelineBehavior |
Recommended Project Structure
├── Controllers
├── Commands
├── Queries
├── Handlers
├── Models
├── DTOs
├── Data
├── Repositories
This structure:
Keeps responsibilities isolated
Scales well as the application grows
Aligns with Clean Architecture principles
High-Level Implementation Flow
Controller receives HTTP request
Command or Query is created
MediatR dispatches the request
Handler executes business logic
Repository/Data layer persists or fetches data
Response is returned to the controller
Controllers remain thin, and business logic lives in handlers.
Advanced MediatR Capabilities
Notifications (Domain Events)
Notifications allow multiple handlers to react to the same event.
Common use cases:
This enables event-driven behavior without tight coupling.
Pipeline Behaviors
Pipeline behaviors act like middleware for MediatR requests.
Typical scenarios:
They provide a clean way to handle cross-cutting concerns without duplicating code.
CQRS Trade-Offs
Advantages
Clear separation of concerns
Highly testable architecture
Easier long-term maintenance
Scales well with complexity
Challenges
CQRS is an architectural investment, not a shortcut.
CQRS in Microservices
CQRS fits naturally into microservices architectures.
MediatR handles in-process CQRS
Message brokers (Kafka, Azure Service Bus) handle cross-service events
Commands and queries can evolve independently
Event-driven systems become easier to manage
CQRS becomes especially powerful when combined with event-based communication.
A step-by-step implementation in ASP.NET Core Web API
![CORS1]()
Model (Domain Entity) - Models/Project.cs
namespace ProjectDemo.Models
{
public class Project
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public string Description { get; set; } = null!;
public DateTime CreatedOn { get; set; }
}
}
DTO (Request & Response) - DTO/CreateProjectRequest.cs ,DTOs/ProjectResponse.cs
namespace ProjectDemo.DTOs
{
public class CreateProjectRequest
{
public string Name { get; set; } = null!;
public string Description { get; set; } = null!;
}
}
namespace ProjectDemo.DTOs
{
public class ProjectResponse
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public string Description { get; set; } = null!;
}
}
Database Context - Data/AppDbContext.cs
using Microsoft.EntityFrameworkCore;
using ProjectDemo.Models;
namespace ProjectDemo.Data
{
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options) { }
public DbSet<Project> Projects => Set<Project>();
}
}
Repository Layer
IProjectRepository.cs ,
using ProjectDemo.Models;
namespace ProjectDemo.Repositories
{
public interface IProjectRepository
{
Task<Project> AddAsync(Project project);
}
}
ProjectRepository.cs
using ProjectDemo.Data;
using ProjectDemo.Models;
namespace ProjectDemo.Repositories
{
public class ProjectRepository : IProjectRepository
{
private readonly AppDbContext _context;
public ProjectRepository(AppDbContext context)
{
_context = context;
}
public async Task<Project> AddAsync(Project project)
{
_context.Projects.Add(project);
await _context.SaveChangesAsync();
return project;
}
}
}
Command (Write Operation) - Commands/CreateProjectCommand.cs
using MediatR;
using ProjectDemo.DTOs;
namespace ProjectDemo.Commands
{
public class CreateProjectCommand : IRequest<ProjectResponse>
{
public string Name { get; set; } = null!;
public string Description { get; set; } = null!;
}
}
Command Handler - Handlers/CreateProjectHandler.cs
using MediatR;
using ProjectDemo.Commands;
using ProjectDemo.DTOs;
using ProjectDemo.Models;
using ProjectDemo.Repositories;
namespace ProjectDemo.Handlers
{
public class CreateProjectHandler
: IRequestHandler<CreateProjectCommand, ProjectResponse>
{
private readonly IProjectRepository _repository;
public CreateProjectHandler(IProjectRepository repository)
{
_repository = repository;
}
public async Task<ProjectResponse> Handle(
CreateProjectCommand command,
CancellationToken cancellationToken)
{
var project = new Project
{
Name = command.Name,
Description = command.Description,
CreatedOn = DateTime.UtcNow
};
var result = await _repository.AddAsync(project);
return new ProjectResponse
{
Id = result.Id,
Name = result.Name,
Description = result.Description
};
}
}
}
Controller (API Endpoint) - Controllers/ProjectsController.cs
using MediatR;
using Microsoft.AspNetCore.Mvc;
using ProjectDemo.Commands;
using ProjectDemo.DTOs;
namespace ProjectDemo.Controllers
{
[ApiController]
[Route("api/projects")]
public class ProjectsController : ControllerBase
{
private readonly IMediator _mediator;
public ProjectsController(IMediator mediator)
{
_mediator = mediator;
}
[HttpPost]
public async Task<IActionResult> CreateProject(
[FromBody] CreateProjectRequest request)
{
var command = new CreateProjectCommand
{
Name = request.Name,
Description = request.Description
};
var response = await _mediator.Send(command);
return Ok(response);
}
}
}
Enable CORS (IMPORTANT) - Program.cs
using MediatR;
using Microsoft.EntityFrameworkCore;
using ProjectDemo.Data;
using ProjectDemo.Repositories;
using System.Reflection;
var builder = WebApplication.CreateBuilder(args);
// Add CORS
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowFrontend",
policy =>
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
// Database
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(
builder.Configuration.GetConnectionString("DefaultConnection")));
// MediatR
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));
// Repositories
builder.Services.AddScoped<IProjectRepository, ProjectRepository>();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Use CORS
app.UseCors("AllowFrontend");
app.UseSwagger();
app.UseSwaggerUI();
app.UseAuthorization();
app.MapControllers();
app.Run();
appsettings.json
{
"ConnectionStrings": {
"DefaultConnection": "Server=.;Database=ProjectDb;Trusted_Connection=True;"
}
}
Request /Response Example (POST)
Request
{
"name": "Inventory System",
"description": "Microservice-based inventory management"
}
Response
{
"id": 1,
"name": "Inventory System",
"description": "Microservice-based inventory management"
}
Conclusion
CQRS combined with MediatR provides a clean, scalable, and maintainable architecture for modern ASP.NET Core Web APIs. By separating read and write concerns and introducing a mediator, applications become easier to evolve, test, and reason about.
While CQRS should not be applied prematurely, it becomes invaluable as systems grow in complexity. When used thoughtfully, it lays a strong foundation for enterprise-grade, future-proof applications.