Implementing CQRS with MediatR and Entity Framework Core in ASP.NET Core Web API (Real-World Guide)
Overview
In my 13+ years of engineering, Iβve seen how traditional CRUD patterns can lead to messy codebases, especially as applications scale. In this article, I discuss implementing CQRS using MediatR and EF Core in ASP.NET Core Web API. This approach improves maintainability, testability, and overall design by separating commands from queries, leveraging clean architecture principles directly in production scenarios.
Real Problem Context
At one point while scaling a product inventory API for a multi-tenant SaaS platform, our tightly coupled CRUD operations started to bog down performance and hinder testability. We needed a clear separation of responsibility, and thatβs when we started implementing CQRS. Our production system suffered from data contention and lack of clear orchestration between write and read models. The introduction of MediatR provided a clean in-memory message bus that simplified command-handling and event notifications. I remember the breakthrough moment when our API latency dropped and debugging became far simpler because the responsibilities were well separated.
Core Concepts
The CQRS pattern splits the read and write workloads into separate models. In our implementation, commands (Create, Update, Delete) are handled through specific MediatR handlers, while queries are isolated to return read-optimized data. Key principles include:
- Decoupling business logic with MediatR to improve testability.
- Using EF Core to access the database in a transactional manner while minimizing side-effects.
- Applying clean architecture to separate concerns, make the system more maintainable, and ensure scalability.
For distributed systems and microservice architectures, this separation enables easier horizontal scaling and maintenance.
Architecture Diagram (ASCII)
+-------------------------------------------------------+ | ASP.NET Core API | | +-----------------+ +---------------------+ | | | Controller |----->| MediatR Dispatcher| | | +-----------------+ +----------+----------+ | | | | | +------------------+------------------+ | | | | +-------v---------+ +-------v---------+ | | Command Handler | | Query Handler | | +-------+---------+ +-------+---------+ | | | | +-------v---------+ +-------v---------+ | | EF Core DbContext | EF Core DbContext (read optimized) | | +-----------------+ +-----------------+ +-------------------------------------------------------+
Deep Dive (Step-by-step)
I usually start by setting up a new ASP.NET Core Web API project and then installing MediatR and EF Core packages. The steps are:
- Create your API controllers that receive HTTP requests.
- Set up MediatR to dispatch commands and queries from your controllers.
- Create separate handler classes for each command and query to logically separate the workload.
- Configure EF Core DbContext within each handler to persist and retrieve data.
- Leverage pipeline behaviors in MediatR for cross-cutting concerns such as validation, logging, and security checks (e.g., token replay protection with nonce/jti validation).
Code Examples
Below are production-grade code examples for the main components using C#, ASP.NET Core, and EF Core.
Controller Example
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace MyApi.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IMediator _mediator;
public ProductsController(IMediator mediator)
{
_mediator = mediator;
}
[HttpGet("{id}")]
public async Task GetProduct(int id) {
var query = new GetProductQuery(id);
var product = await _mediator.Send(query);
return product != null ? Ok(product) : NotFound();
}
[HttpPost]
public async Task CreateProduct(CreateProductCommand command) {
var result = await _mediator.Send(command);
return CreatedAtAction(nameof(GetProduct), new { id = result.Id }, result);
}
}
}
MediatR Handlers (Command & Query)
using MediatR;
using System.Threading;
using System.Threading.Tasks;
public record GetProductQuery(int Id) : IRequest;
public record CreateProductCommand(string Name, decimal Price) : IRequest;
public class GetProductQueryHandler : IRequestHandler
{
private readonly AppDbContext _dbContext;
public GetProductQueryHandler(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task Handle(GetProductQuery request, CancellationToken cancellationToken) {
var product = await _dbContext.Products.FindAsync(new object[] { request.Id }, cancellationToken);
if (product == null) return null;
return new ProductDto(product.Id, product.Name, product.Price);
}
}
public class CreateProductCommandHandler : IRequestHandler
{
private readonly AppDbContext _dbContext;
public CreateProductCommandHandler(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task Handle(CreateProductCommand request, CancellationToken cancellationToken) {
var product = new Product { Name = request.Name, Price = request.Price };
_dbContext.Products.Add(product);
await _dbContext.SaveChangesAsync(cancellationToken);
return new ProductDto(product.Id, product.Name, product.Price);
}
}
EF Core DbContext Configuration
using Microsoft.EntityFrameworkCore;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions options) : base(options) { }
public DbSet Products { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) {
// Use schema switching, model caching and other production optimizations
modelBuilder.Entity().ToTable("Products", "dbo");
base.OnModelCreating(modelBuilder);
}
}
Docker & Kubernetes Manifests
# Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base
WORKDIR /app
EXPOSE 80
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /src
COPY ["MyApi.csproj", "."]
RUN dotnet restore "."
COPY . .
RUN dotnet publish "MyApi.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]
# Kubernetes Deployment Manifest
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapi-deployment
spec:
replicas: 3
selector:
matchLabels:
app: myapi
template:
metadata:
labels:
app: myapi
spec:
containers:
- name: myapi
image: myregistry.azurecr.io/myapi:latest
ports:
- containerPort: 80
env:
- name: ASPNETCORE_ENVIRONMENT
value: "Production"
Folder Structure
π src
βββπ MultiTenancy
β βββπ TenantResolverMiddleware.cs
β βββπ TenantContext.cs
β βββπ ITenantProvider.cs
β βββπ TenantModelCacheKeyFactory.cs
βββπ Infrastructure
βββπ Persistence
βββπ Migrations
βββπ ConnectionStringFactory.cs
βββπ Features
βββπ Orders
βββπ Customers
βββπ Shared
βββπ Domain
βββπ Entities
βββπ ValueObjects
βββπ Services
βββπ Shared
βββπ Behaviors
Best Practices
When working on CQRS with MediatR and EF Core, I focus on the following best practices:
- Ensure each command and query is clearly separated and follows single responsibility.
- Implement MediatR pipeline behaviors for cross-cutting concerns such as logging, validation, and security (including token replay protection and claims-based authorization).
- Manage DbContext lifetimes carefully to avoid issues with pooling and concurrency, especially in a multi-tenant environment.
- Utilize EF Core features like model caching and schema switching to optimize performance.
- Adopt a robust DevOps pipeline using Docker, Terraform, and Kubernetes for smooth deployments.
Common Pitfalls & Anti-Patterns
Avoid mixing read and write operations in the same handler. Do not overuse MediatR for trivial operations, and be mindful of excessive abstraction that could obscure performance issues. Also, ensure you maintain proper security boundaries β use API gateway token forwarding and enforce cross-tenant boundaries where applicable.
Performance & Scalability Considerations
In production, itβs essential to design your CQRS components for scalability:
- Implement idempotency tokens and retry policies with exponential backoff to handle transient faults.
- Use distributed locking when provisioning resources or migrating tenant databases.
- Leverage horizontal scaling models and consider using read replicas for the query model.
- Ensure that caching topologies (for example, Redis with per-tenant partitions) are implemented judiciously to reduce database load.
Real-World Use Cases
In a recent financial multi-tenant system, we implemented a similar CQRS architecture. The command-handlers dealt with intricate business validations, while query-handlers were optimized for speed. This separation not only improved troubleshooting but also paved the way for a future-ready modular design. Iβve seen how a clean segregation between the read and write workloads has allowed us to scale each component independently, a lesson reinforced by industry experts like Martin Fowler and Greg Young.
When NOT to Use This
CQRS with MediatR is ideal for complex domains; however, for simple CRUD applications, the overhead of implementing full-blown CQRS may not deliver proportional benefits. If your system does not require clear separation between read and write operations or does not face high performance constraints, a simpler approach might be more appropriate.
Conclusion
Implementing CQRS with MediatR and EF Core in an ASP.NET Core Web API is a powerful pattern for scalable and maintainable systems. By carefully separating concerns and leveraging best practices, you can build an architecture that stands up to real-world production demands. As always, my advice is rooted in hands-on experience from challenging projects. Experiment, measure, and iterate β your system will thank you for it!