CQRS in C# — A Complete Guide with Real Code (Why, How, and When NOT to Use It)
Deep-dive into CQRS in .NET: the problem it solves, architecture diagram, full C# implementation with MediatR, EF Core, separate read/write models, when CQRS pays off vs when it's over-engineering. With real code.
- Author
- Randhir Jassal
- Published
- Reading time
- 18 min read
CQRS stands for Command Query Responsibility Segregation. It splits an application into two clearly separated halves: one for changing state (commands) and one for reading state (queries). Each half can have its own model, its own database, its own scaling strategy.
This guide is the complete picture: what problem CQRS actually solves, when to reach for it, when it's catastrophically over-engineered, the architecture, real C# code with MediatR + EF Core, and the production reality nobody mentions in the talks.
The problem CQRS solves
Traditional "CRUD" code uses one model for both writes and reads. The same Order class is used to:
- Validate input on
POST /orders(write — needs business rules, invariants) - Save to DB (write — needs change tracking, transactions)
- Return for
GET /orders/123(read — should be flat, fast, denormalized) - Render the orders list (read — needs joins to customer + items)
- Generate reports (read — needs aggregations, materialized views)
One model serving five very different needs leads to:
| Symptom | Cause |
|---|---|
| Bloated DTOs with 40 fields | Trying to cover every read scenario |
| Slow lists with N+1 joins | Read paths fighting the normalized write schema |
| Validation logic mixed with rendering logic | Same class trying to do both |
| Cannot scale reads independently | Same DB serves both |
| Cannot cache aggressively | Stale-on-write surprises break the page |
CQRS solves this by saying: stop pretending one model fits both. Use a write model optimized for business rules + invariants. Use a separate read model optimized for the queries the UI actually issues.
The architecture in one diagram
CQRS Architecture
─────────────────
┌────────────┐ ┌────────────┐
│ Browser / │ POST /orders │ Browser / │
│ Mobile │ ───────────────────┐ ┌────────── │ Mobile │
└────────────┘ │ │ └────────────┘
│ │ GET /orders/123
▼ ▼
┌─────────────────────────┐
│ ASP.NET Core API │
│ (Controllers) │
└──────────┬──────────────┘
│
│ IMediator.Send(...)
│
┌──────────┴──────────┐
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Command Side │ │ Query Side │
│ │ │ │
│ PlaceOrderHandler│ │ GetOrderHandler │
│ CancelHandler │ │ ListOrdersHandler│
│ ShipHandler │ │ DashboardHandler │
│ │ │ │
│ Validates + │ │ Optimized SQL, │
│ applies business│ │ no validation, │
│ rules, writes │ │ reads only │
└────────┬─────────┘ └────────┬─────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Write DB │ │ Read DB │
│ (Normalized) │ ◀ │ (Denormalized) │
│ │ │ │
│ Orders │ │ OrderListItem │
│ OrderItems │ │ OrderDashboard │
│ Customers │ │ CustomerStats │
│ Payments │ │ │
└────────┬─────────┘ └──────────────────┘
│ ▲
│ Domain Events │
│ (OrderPlaced...) │
└─────────────────────┘
"projector" updates
read model on changes
- Write side: normalized DB, business rules, invariants
- Read side: shaped exactly for the UI's queries, fast, can be cached
- Both sides sync via domain events (the "projector")
Three properties that hold:
- The write side knows business rules. "Can this order be cancelled? Is the stock available? Does the customer have credit?" All those checks live in command handlers.
- The read side is "stupid SQL". No business logic. It returns rows shaped exactly like the UI wants them. Easy to optimize.
- They sync asynchronously via events. When a command commits, it emits a domain event. A projector listens, updates the read model.
Important: CQRS does NOT require two databases. The simplest, most common form uses one database with separate read and write queries — but the same physical schema. Two databases is an advanced variant.
CQRS in C# — minimal implementation
Step 1 — Install MediatR
dotnet add package MediatR
MediatR is the de-facto C# library for routing commands and queries to handlers. It's lightweight, well-tested, and ubiquitous in .NET CQRS codebases.
Step 2 — Define base contracts
public interface ICommand<TResult> : IRequest<TResult> { }
public interface IQuery<TResult> : IRequest<TResult> { }
IRequest<T> comes from MediatR. We wrap it so the intent is obvious at the call site.
Step 3 — A command + its handler
// The command — describes WHAT the user wants to do, nothing else
public record PlaceOrderCommand(
Guid CustomerId,
List<LineItem> Items) : ICommand<Guid>;
public record LineItem(Guid ProductId, int Quantity);
// The handler — runs the business logic
public class PlaceOrderHandler : IRequestHandler<PlaceOrderCommand, Guid>
{
private readonly AppDb _db;
private readonly IInventoryService _inventory;
public PlaceOrderHandler(AppDb db, IInventoryService inventory)
{
_db = db;
_inventory = inventory;
}
public async Task<Guid> Handle(PlaceOrderCommand cmd, CancellationToken ct)
{
if (cmd.Items.Count == 0)
throw new ValidationException("Order must have at least one item");
var stockOk = await _inventory.CheckAvailabilityAsync(cmd.Items, ct);
if (!stockOk)
throw new ConflictException("Some items are out of stock");
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = cmd.CustomerId,
Status = OrderStatus.Placed,
CreatedAt = DateTimeOffset.UtcNow,
Items = cmd.Items.Select(i => new OrderItem
{
ProductId = i.ProductId,
Quantity = i.Quantity
}).ToList()
};
_db.Orders.Add(order);
// Emit a domain event in the same transaction (outbox pattern)
_db.OutboxEvents.Add(new OutboxEvent
{
Topic = "order.placed",
Payload = JsonSerializer.Serialize(new
{
orderId = order.Id,
customerId = order.CustomerId,
itemCount = order.Items.Count
})
});
await _db.SaveChangesAsync(ct);
return order.Id;
}
}
Step 4 — A query + its handler
// The query — describes WHAT the UI wants to read
public record GetOrderQuery(Guid OrderId) : IQuery<OrderDetailDto?>;
// The DTO — shaped EXACTLY for the UI, not for the DB
public record OrderDetailDto(
Guid Id,
string Status,
string CustomerName,
decimal Total,
List<OrderLineDto> Lines,
DateTimeOffset PlacedAt);
public record OrderLineDto(string ProductName, int Quantity, decimal LineTotal);
// The handler — pure read, no business logic, optimized SQL
public class GetOrderHandler : IRequestHandler<GetOrderQuery, OrderDetailDto?>
{
private readonly AppDb _db;
public GetOrderHandler(AppDb db) => _db = db;
public async Task<OrderDetailDto?> Handle(GetOrderQuery q, CancellationToken ct)
{
return await _db.Orders
.Where(o => o.Id == q.OrderId)
.Select(o => new OrderDetailDto(
o.Id,
o.Status.ToString(),
o.Customer.Name,
o.Items.Sum(i => i.Quantity * i.Product.Price),
o.Items.Select(i => new OrderLineDto(
i.Product.Name,
i.Quantity,
i.Quantity * i.Product.Price
)).ToList(),
o.CreatedAt
))
.FirstOrDefaultAsync(ct);
}
}
Step 5 — The controller — thin, just routes to MediatR
[ApiController]
[Route("orders")]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator) => _mediator = mediator;
[HttpPost]
public async Task<ActionResult<Guid>> Place([FromBody] PlaceOrderCommand cmd, CancellationToken ct)
{
var orderId = await _mediator.Send(cmd, ct);
return CreatedAtAction(nameof(Get), new { id = orderId }, orderId);
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<OrderDetailDto>> Get(Guid id, CancellationToken ct)
{
var result = await _mediator.Send(new GetOrderQuery(id), ct);
return result is null ? NotFound() : Ok(result);
}
}
That's the full pattern. Each command has its own handler. Each query has its own handler. The controller does no business logic; it just routes to MediatR.
Step 6 — Wire it up in Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDb>(o => o.UseNpgsql(connStr));
builder.Services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(Program).Assembly));
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();
That's it. MediatR auto-discovers every IRequestHandler<,> in the assembly. New commands and queries plug in by just adding files — no wiring, no registry.
The killer feature — pipeline behaviors
MediatR lets you add cross-cutting concerns once, applied to every command + query, with no boilerplate in handlers.
Logging behavior
public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly ILogger<TRequest> _log;
public LoggingBehavior(ILogger<TRequest> log) => _log = log;
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
var sw = Stopwatch.StartNew();
try
{
return await next();
}
finally
{
_log.LogInformation("{Req} took {Ms}ms", typeof(TRequest).Name, sw.ElapsedMilliseconds);
}
}
}
Validation behavior (with FluentValidation)
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
var ctx = new ValidationContext<TRequest>(request);
var errors = (await Task.WhenAll(_validators.Select(v => v.ValidateAsync(ctx, ct))))
.SelectMany(r => r.Errors)
.Where(e => e != null)
.ToList();
if (errors.Count > 0) throw new ValidationException(errors);
return await next();
}
}
public class PlaceOrderValidator : AbstractValidator<PlaceOrderCommand>
{
public PlaceOrderValidator()
{
RuleFor(x => x.CustomerId).NotEmpty();
RuleFor(x => x.Items).NotEmpty().WithMessage("Order needs at least one item");
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(i => i.Quantity).GreaterThan(0).LessThanOrEqualTo(100);
});
}
}
Wire the behaviors
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
Now EVERY command and query gets logged + validated. Add more behaviors (caching, transactions, retries) the same way. Handlers stay tiny — they only do business logic.
Read model variants — single DB vs separate DB
CQRS doesn't mandate two databases. Pick based on actual need.
Variant A — Same DB, separate queries (most common, recommended starting point)
// Write side
public class Order { Id; CustomerId; Status; Items; ... } // EF Core entity
// Read side — direct SQL or LINQ projection, doesn't touch the entity
public class GetOrderHandler {
public Task<OrderDetailDto?> Handle(...) => _db.Orders
.Select(o => new OrderDetailDto(...)) // projection ONLY for the UI
.FirstOrDefaultAsync();
}
✅ Single DB to operate. ✅ No eventual consistency. ✅ Massive 80% of the perf + clarity benefit. Use this first.
Variant B — Separate read DB (advanced, eventual consistency)
Write DB (Postgres normalized) ─── domain events ───▶ Read DB (Mongo / Elastic / denormalized SQL)
When you reach for this:
- Read load is 100x write load (e-commerce browse vs purchase)
- The UI's most-used view is a complex aggregation that's expensive to compute on every read
- You want to use a fundamentally different storage tech for reads (Elastic for search, ClickHouse for analytics)
Trade-off: eventual consistency window (200ms-2s typical). UI must handle "your order was placed but isn't in the list yet".
Variant C — Separate read DB + Event Sourcing
The most advanced form. The write side stores ONLY events. The read model is built by projecting events.
This is a separate topic — read our SAGA + Event Sourcing posts for that depth.
Advantages of CQRS
- Clear separation of intent —
PlaceOrderCommandis obviously a write.GetOrderQueryis obviously a read. No 800-line "service" doing both. - Optimised read paths — read DTOs are shaped exactly for the UI. No bloated entity classes.
- Independent scaling — read replicas for queries, primary for commands. Add Redis cache only on queries.
- Easier testing — each handler is a small class with one job. Test in isolation, mock dependencies, done.
- Easier auditing — every state change has a named command. The command log IS the audit log.
- Easier security — authorize per command (
[Authorize(Roles="admin")] OnHandle(CancelOrderCommand)) instead of per controller method. - Pipeline behaviors — cross-cutting concerns (validation, logging, retry, transactions, caching) added once, applied everywhere.
Disadvantages
- More files — every operation becomes a command class + handler class + DTO. 3x the file count vs vanilla controllers.
- Indirection — call site says
_mediator.Send(cmd). Where's the actual code? Two clicks away in Visual Studio. - Overhead for trivial CRUD — "Get user by ID" with no business logic doesn't need MediatR ceremony.
- Eventual consistency (only with separate read DB) — UX surprise: write succeeds, read still shows old value.
- Learning curve — junior devs need to understand the pattern before they're productive.
- Pipeline ordering matters — validation BEFORE transaction BEFORE handler BEFORE caching. Getting the order wrong silently breaks things.
When CQRS pays off
- The app has distinct write and read scaling profiles (1000 reads per write)
- Multiple complex business operations with real invariants ("close month-end", "approve loan", "transfer funds")
- Many different read shapes for the same data (list, detail, dashboard, report, export)
- Audit / regulatory requirements — every state change must be named and logged
- Multiple input channels (HTTP + queue consumer + cron) hitting the same business logic
- Team size 5+ — the structure forces good boundaries, eases onboarding
When CQRS is over-engineering
- Simple CRUD admin tools — table-of-rows with edit-row-in-modal. Don't.
- Internal tools with one developer maintaining them
- A backend whose entire job is "store JSON I get from the client"
- MVP / pre-product-market-fit — first ship, optimise later
- Prototypes — you'll throw away the code anyway
Honest rule: if you cannot describe in one sentence what the COMMAND side does differently from the QUERY side, you don't need CQRS. Use plain ASP.NET Core MVC / Minimal APIs and stop.
CQRS vs Event Sourcing — they are NOT the same
Beginners conflate these constantly. They're orthogonal.
| CQRS | Event Sourcing | |
|---|---|---|
| What it splits | Read code from write code | Current state from history |
| Storage | Could be one DB, two DBs, anything | Append-only event log |
| Replay | No | Yes — replay events to rebuild state |
| Complexity | Medium | High |
| Used together? | Often | Rarely without CQRS |
You can do CQRS without Event Sourcing. Most production CQRS systems do exactly this. The write side uses normalized SQL like always; only the read side gets the bespoke shape treatment. Don't add event sourcing unless your domain genuinely needs the event log.
Common pitfalls
- Anaemic commands — a
UpdateOrderCommandwith 25 nullable fields ("update whatever's not null"). That's CRUD with extra steps. Split into specific commands:CancelOrder,ShipOrder,ReassignOrderToCustomer. - Business logic in queries —
GetEligibleCustomersQuerythat calculates eligibility on every read. Should be a projection updated on relevant commands. - Returning entities from queries — defeats the read-model freedom. Always return DTOs.
- Pipeline behaviors in the wrong order — validation MUST run before the transaction begins. Logging should wrap everything.
- Adding Event Sourcing too early — vastly increases complexity for benefits most apps don't need.
- Two DBs for a small app — eventual consistency UX problems for read load that fits in one Postgres instance.
Real-world fit
| Domain | CQRS fit |
|---|---|
| E-commerce checkout | ✅ Strong — clear commands (PlaceOrder, Cancel, Ship), many distinct read views |
| Banking / accounting | ✅ Strong — audit trail by design, strict business rules |
| SaaS admin dashboards | ⚠️ Modest — only if there are many read views per write |
| Content management / blogs | ❌ Skip — CRUD is fine; CQRS is overhead |
| Internal CRUD tools | ❌ Skip — single dev, single use case |
| Real-time chat | ❌ Different problem — use a state machine + WebSockets |
Production checklist
- Each command has at most one handler (enforced at boot, fail fast)
- Each command has a
FluentValidationvalidator - Every state-changing handler runs in a DB transaction (via behavior or
IUnitOfWork) - Domain events are emitted via the outbox pattern in the same transaction
- Idempotency keys on commands that might be retried (payment, charge, send)
- Authorization checked in a pipeline behavior or attribute, not in the handler body
- Query handlers are pure — no
SaveChanges, no side effects - DTOs returned by queries are shaped for the UI, never
EntityorEntitywithInclude() - Pipeline order in
Program.cs: Logging → Validation → Authorization → Transaction → Handler → Caching
Summary
CQRS gives you a clean architectural seam between business writes and UI reads. Done right, it pays off enormously in maintainability, scaling, and team velocity once the codebase grows past ~20 endpoints.
Done wrong — or applied to a small CRUD app — it adds files, indirection, and onboarding pain without delivering the benefits.
The right way to adopt it in 2026:
- Start with one DB, MediatR, FluentValidation, pipeline behaviors
- Split commands and queries cleanly
- Skip Event Sourcing unless the domain demands it
- Skip a separate read DB unless your read load demands it
- Migrate from controller-based services to MediatR incrementally — don't big-bang
Pair it with the outbox pattern for reliable event emission, and the SAGA pattern for cross-service workflows. Together those three are the backbone of any serious .NET service-oriented architecture.
📚 Test your knowledge → Practice with our CQRS interview questions — MediatR patterns, command/query boundaries, read-model sync, eventual-consistency gotchas, and when NOT to use CQRS.
Get the next issue
A short, curated email with the newest posts and questions.