Software Architecture
CQRS in .NET 10: What It Solves and How to Implement It with MediatR and C# 14
You start with a single model. One entity class handles displaying product listings, processing orders, generating reports, and feeding the search index. It works at first. Then the product catalog grows to millions of items, the order processing needs complex validation workflows, and the reports require denormalized views that conflict with the normalized structure your write operations demand. You have hit the wall that every growing application eventually reaches: reads and writes have fundamentally different requirements, and forcing them through the same model creates friction that slows your entire team. CQRS — Command Query Responsibility Segregation — addresses this tension directly. It separates your application into two distinct paths: one optimized for changing state, another optimized for reading it. In this article, you will learn what CQRS actually is, which specific problems it solves, when you should and should not use it, and how to implement it from scratch in .NET 10 with C# 14 using MediatR as the dispatching mechanism.
What Is CQRS
CQRS stands for Command Query Responsibility Segregation. The concept originates from Bertrand Meyer's Command Query Separation (CQS) principle, which states that every method should either be a command that performs an action or a query that returns data, but never both. Greg Young extended this principle from method-level design to architectural-level design, proposing that entire models should be separated: one model for writes (commands), another for reads (queries).
The core insight behind CQRS is straightforward: reads and writes have different non-functional requirements. Write operations need validation, business rule enforcement, transactional consistency, and domain event publishing. Read operations need speed, flexible projections, denormalized data shapes, and caching. When you force both concerns into a single model, you compromise both. Your read queries drag along validation logic they do not need. Your write operations carry projection fields that exist only for display purposes.
A critical clarification: CQRS is not Event Sourcing. These two patterns are frequently mentioned together, but they are independent concepts. You can implement CQRS with a traditional relational database and no event store whatsoever. Event Sourcing is a persistence strategy; CQRS is a segregation strategy. Combining them can be powerful, but adopting CQRS does not require adopting Event Sourcing.
Technical insight: CQRS does not mandate separate databases. The simplest implementation uses a single database with separate read and write models in code. You can evolve toward physically separated stores later if performance demands it, but starting with logical separation delivers most of the architectural benefits immediately.
Practical scenario: Consider an e-commerce platform where the product listing page needs to display product name, price, average rating, stock status, and seller information from five different tables. The order creation process needs to validate inventory, apply discount rules, calculate taxes, and update stock counts. Using a single Product entity for both operations means the listing query loads unnecessary navigation properties, while the order process carries display-only fields through the validation pipeline. CQRS eliminates this tension by giving each path exactly the model it needs.
What Problems Does CQRS Solve
Read/Write Optimization
In traditional CRUD architectures, the same data model serves both read and write operations. This creates a fundamental compromise: you normalize the schema for write integrity, but reads suffer because they require expensive joins across normalized tables. Alternatively, you denormalize for read performance, but writes become complex because you must maintain redundant data consistently. CQRS eliminates this trade-off entirely. The write side uses a normalized model optimized for consistency and validation. The read side uses denormalized projections, flat DTOs, or even pre-computed views optimized for the exact shape each UI component needs. Neither side compromises for the other.
Independent Scalability
Most applications are read-heavy. A typical web application handles ten to one hundred times more read requests than write requests. In a monolithic model, scaling reads means scaling the entire model, including all the write-side complexity. CQRS allows you to scale read and write paths independently. You can deploy read replicas, add caching layers to the query side, or even use a different database technology for reads — all without affecting write operations. The command side can remain on a single, strongly consistent database node while the query side fans out across multiple read replicas.
Separation of Concerns and Single Responsibility
A single service class that handles both CreateOrder and GetOrderDetails violates the Single Responsibility Principle. The create operation needs validation, authorization, domain logic, and event publishing. The read operation needs data projection, caching, and pagination. These are fundamentally different responsibilities with different change frequencies. CQRS enforces this separation structurally: command handlers contain only write logic, query handlers contain only read logic. When the business rules for order creation change, you modify a command handler without risking side effects on read operations. When a new dashboard requires a different data shape, you add a query handler without touching write logic.
Complexity Management in Large Domains
As domain complexity grows, the gap between what you need to write and what you need to read widens dramatically. A financial trading system might accept a simple PlaceOrderCommand with five fields but display an order through a read model joining twelve tables with real-time market data, risk calculations, and compliance flags. Trying to serve both through a single repository method creates methods with dozens of parameters, optional includes, and conditional logic. CQRS keeps each side focused on its own complexity budget. Command handlers deal with business rules. Query handlers deal with data assembly. Neither drowns in the other's concerns.
Technical insight: The separation of concerns in CQRS also enables parallel team development. One team can work on command handlers and domain logic while another team optimizes query performance and builds new read projections. The only shared contract is the database schema, and even that can be decoupled with event-driven synchronization between write and read stores.
When to Use CQRS and When Not To
CQRS adds structural complexity. That complexity is justified only when the problems it solves are actually present in your application. Use CQRS when:
- Read and write models diverge significantly in shape or volume
- You need independent scalability for reads and writes
- Domain logic is complex enough to warrant dedicated command handlers
- Multiple teams work on different aspects of the same bounded context
- You need audit trails or temporal queries on the write side
- Performance requirements differ between read and write paths
Avoid CQRS when:
- Your application is a simple CRUD with minimal business logic
- Read and write models are nearly identical
- Your team is small and the added indirection slows development
- The domain is simple enough that a single repository pattern suffices
| Dimension | Traditional CRUD | CQRS |
|---|---|---|
| Model count | Single shared model | Separate read/write models |
| Scalability | Scale everything together | Scale read/write independently |
| Complexity | Low initial, grows fast | Higher initial, grows linearly |
| Performance optimization | One-size-fits-all | Targeted per path |
| Team parallelism | Merge conflicts on shared models | Independent work streams |
| Testing | Integration-heavy | Unit-testable handlers |
| Best for | Simple domains, small teams | Complex domains, read-heavy apps |
Technical insight: Start without CQRS. Build your application with a simple repository pattern. When you notice that your read DTOs diverge from your write entities, that your service methods contain conditional logic for "is this a read or a write," or that your query performance degrades because of write-optimized schemas — that is the signal to refactor toward CQRS. The pattern is easier to adopt incrementally than to remove retroactively.
Implementing CQRS in .NET 10 with MediatR and C# 14
Project Structure in Clean Architecture
A clean CQRS implementation maps naturally to Clean Architecture layers. Commands and queries live in the Application layer. Handlers reference domain entities and infrastructure abstractions through interfaces. The API layer dispatches requests through MediatR without knowing which handler processes them.
src/
├── MyApp.Domain/
│ ├── Entities/
│ │ └── Order.cs
│ ├── Enums/
│ │ └── OrderStatus.cs
│ └── Interfaces/
│ └── IOrderRepository.cs
├── MyApp.Application/
│ ├── Orders/
│ │ ├── Commands/
│ │ │ ├── CreateOrder/
│ │ │ │ ├── CreateOrderCommand.cs
│ │ │ │ ├── CreateOrderCommandHandler.cs
│ │ │ │ └── CreateOrderCommandValidator.cs
│ │ │ └── UpdateOrder/
│ │ │ ├── UpdateOrderCommand.cs
│ │ │ └── UpdateOrderCommandHandler.cs
│ │ └── Queries/
│ │ └── GetOrderById/
│ │ ├── GetOrderByIdQuery.cs
│ │ ├── GetOrderByIdQueryHandler.cs
│ │ └── OrderDto.cs
│ └── Common/
│ └── Behaviors/
│ └── ValidationBehavior.cs
├── MyApp.Infrastructure/
│ ├── Persistence/
│ │ ├── AppDbContext.cs
│ │ └── Repositories/
│ │ └── OrderRepository.cs
│ └── DependencyInjection.cs
└── MyApp.Api/
├── Program.cs
└── Endpoints/
└── OrderEndpoints.cs
Defining Commands and Queries
Commands represent intentions to change state. Queries represent requests for data. Both are simple data carriers — records in C# 14 are ideal because they provide value semantics, immutability, and concise syntax.
public record CreateOrderCommand(
Guid CustomerId,
string ShippingAddress,
List<OrderItemDto> Items) : IRequest<CreateOrderResult>;
public record CreateOrderResult(Guid OrderId, decimal TotalAmount);
public record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDto?>;
public record OrderDto(
Guid Id,
string CustomerName,
string ShippingAddress,
decimal TotalAmount,
string Status,
DateTime CreatedAt,
List<OrderItemDto> Items);
Notice the asymmetry: the command carries only the data needed to create an order. The query returns a rich DTO with computed fields and joined data that the command never needs. This is the essence of CQRS — each path carries exactly the data it requires.
Command Handler Implementation
Command handlers contain the write-side business logic. They validate business rules, modify domain entities, and persist changes. Using primary constructors from C# 14 keeps handlers concise.
public sealed class CreateOrderCommandHandler(
IOrderRepository orderRepository,
IUnitOfWork unitOfWork)
: IRequestHandler<CreateOrderCommand, CreateOrderResult>
{
public async Task<CreateOrderResult> Handle(
CreateOrderCommand request,
CancellationToken cancellationToken)
{
var order = Order.Create(
request.CustomerId,
request.ShippingAddress);
foreach (var item in request.Items)
{
order.AddItem(item.ProductId, item.Quantity, item.UnitPrice);
}
orderRepository.Add(order);
await unitOfWork.SaveChangesAsync(cancellationToken);
return new CreateOrderResult(order.Id, order.TotalAmount);
}
}
Query Handler Implementation
Query handlers focus exclusively on reading data. They use read-optimized strategies: AsNoTracking() to skip Entity Framework change detection, projection with Select to load only needed columns, and direct DTO mapping to avoid materializing full entity graphs.
public sealed class GetOrderByIdQueryHandler(
AppDbContext context)
: IRequestHandler<GetOrderByIdQuery, OrderDto?>
{
public async Task<OrderDto?> Handle(
GetOrderByIdQuery request,
CancellationToken cancellationToken)
{
return await context.Orders
.AsNoTracking()
.Where(o => o.Id == request.OrderId)
.Select(o => new OrderDto(
o.Id,
o.Customer.Name,
o.ShippingAddress,
o.TotalAmount,
o.Status.ToString(),
o.CreatedAt,
o.Items.Select(i => new OrderItemDto(
i.ProductId,
i.ProductName,
i.Quantity,
i.UnitPrice
)).ToList()
))
.FirstOrDefaultAsync(cancellationToken);
}
}
Technical insight: Notice that the query handler injects AppDbContext directly rather than going through a repository. This is intentional. Repositories add value on the write side by encapsulating persistence logic and enforcing aggregate boundaries. On the read side, repositories add unnecessary abstraction because queries are projections, not domain operations. Querying the DbContext directly gives you full access to LINQ projections, joins, and optimizations that a repository pattern would need to expose through increasingly complex method signatures.
MediatR Configuration and Pipeline
Registration in Program.cs connects everything through a single call to AddMediatR, which scans the assembly for all handlers and registers pipeline behaviors in order.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssemblyContaining<CreateOrderCommandHandler>();
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
});
builder.Services.AddValidatorsFromAssemblyContaining<CreateOrderCommandValidator>();
var app = builder.Build();
app.MapPost("/api/orders", async (CreateOrderCommand command, IMediator mediator) =>
{
var result = await mediator.Send(command);
return Results.Created($"/api/orders/{result.OrderId}", result);
});
app.MapGet("/api/orders/{id:guid}", async (Guid id, IMediator mediator) =>
{
var order = await mediator.Send(new GetOrderByIdQuery(id));
return order is not null ? Results.Ok(order) : Results.NotFound();
});
app.Run();
Practical scenario: A fintech startup migrated from a monolithic service layer with 47 methods to CQRS with MediatR. Each handler became a focused unit with a single responsibility. Their test coverage jumped from 34% to 82% because handlers were independently testable without mocking half the application. Code reviews improved because each pull request modified exactly one handler, making the change scope immediately obvious.
CQRS Flow: From Request to Response
Integration with Clean Architecture
CQRS and Clean Architecture are natural partners. Clean Architecture provides the layer boundaries; CQRS provides the structural pattern for how operations flow through those layers. The Application layer becomes the heart of CQRS, housing all commands, queries, their handlers, and the interfaces that define infrastructure dependencies.
The Domain layer remains pure: entities, value objects, and domain events with zero knowledge of CQRS mechanics. The Application layer contains the CQRS infrastructure: commands with their handlers and validators, queries with their handlers and DTOs, and pipeline behaviors like validation and logging. The Infrastructure layer implements the repository interfaces defined in Domain and provides the DbContext used by query handlers. The API layer stays thin: it receives HTTP requests, constructs the appropriate command or query, dispatches it through MediatR, and returns the result.
This separation means the Application layer can be tested entirely in isolation. Command handlers are unit-tested with mocked repositories. Query handlers are tested with an in-memory database. Pipeline behaviors are tested by verifying they correctly intercept and validate requests. None of these tests require an HTTP server, a real database, or any infrastructure dependency.
Practical scenario: A development team restructuring a monolithic e-commerce application into CQRS with Clean Architecture found that their biggest initial win was not performance — it was developer onboarding. New team members could look at the Application/Orders/Commands folder and immediately understand every write operation the system supports. The folder structure became living documentation. Previously, the same operations were spread across three service classes totaling 2,400 lines, and understanding the system required tribal knowledge.
Integration with FluentValidation
CQRS separates commands from queries. FluentValidation ensures that commands carry valid data before they reach handlers. The integration point is MediatR's IPipelineBehavior<TRequest, TResponse>, which intercepts every request and runs all registered validators before allowing the handler to execute.
public sealed class ValidationBehavior<TRequest, TResponse>(
IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
if (!validators.Any())
return await next(cancellationToken);
var context = new ValidationContext<TRequest>(request);
var results = await Task.WhenAll(
validators.Select(v => v.ValidateAsync(context, cancellationToken)));
var errors = results
.SelectMany(r => r.Errors)
.Where(e => e is not null)
.ToList();
if (errors.Count is not 0)
throw new ValidationException(errors);
return await next(cancellationToken);
}
}
Each command gets its own validator class. A CreateOrderCommandValidator validates the order creation fields. The behavior runs all registered IValidator<CreateOrderCommand> instances automatically. No command handler ever needs to check for validation — the pipeline guarantees that only valid commands arrive.
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.CustomerId)
.NotEmpty().WithMessage("Customer is required.");
RuleFor(x => x.ShippingAddress)
.NotEmpty().WithMessage("Shipping address is required.")
.MaximumLength(500);
RuleFor(x => x.Items)
.NotEmpty().WithMessage("Order must contain at least one item.");
}
}
For a deep dive into FluentValidation, including async validation, testing strategies, and advanced rule composition, see the complete FluentValidation guide.
Performance and Scalability Considerations
MediatR adds negligible runtime overhead. The dispatcher uses reflection at startup to build a handler map, but request dispatching itself is a dictionary lookup followed by a delegate invocation. In benchmarks, MediatR adds less than one microsecond per request compared to direct method calls. This cost is invisible next to any database operation or network call in your pipeline.
The real performance gains in CQRS come from optimizing each path independently. On the query side, use AsNoTracking() with Entity Framework to skip change detection, reducing memory allocation and CPU usage by up to 40% for read operations. For high-throughput read scenarios, consider Dapper for raw SQL queries that bypass EF entirely. Read replicas in SQL Server or PostgreSQL can distribute query load across multiple database instances without affecting write consistency.
On the command side, handlers should focus on a single aggregate. Loading the minimum necessary state, applying domain logic, and persisting changes through IUnitOfWork keeps write transactions short and reduces lock contention. Avoid loading large object graphs for operations that only modify a single entity.
Caching becomes straightforward with CQRS. Query results are naturally cacheable because they represent point-in-time snapshots with no side effects. Command results should never be cached because they represent state changes. This clear distinction eliminates the cache invalidation complexity that plagues CRUD architectures where a single method both reads and writes.
For cloud-native deployments, CQRS enables targeted scaling. Deploy query handlers behind auto-scaling groups with aggressive caching. Deploy command handlers on compute instances with fast, transactional database connections. Each path scales according to its actual demand pattern, not the combined worst-case of both.
Common Developer Mistakes
After reviewing dozens of CQRS implementations across production systems, these six mistakes appear consistently:
- Putting query logic inside command handlers. A command handler that returns a fully populated DTO with joined data is doing two jobs. Commands should return minimal confirmation data — an ID and a status. If the caller needs the full entity after creation, dispatch a separate query.
- Using the same DbContext configuration for reads and writes. Entity Framework's change tracker adds overhead that read operations do not need. Configure a read-only DbContext with
QueryTrackingBehavior.NoTrackingby default for query handlers, and a separate tracked context for command handlers. - Naming commands after CRUD operations instead of business intentions.
UpdateProductCommandtells you nothing about the business operation.AdjustProductPriceCommand,RetireProductCommand, andRestockProductCommandeach carry clear business intent and can have specific validation rules. CQRS works best when commands reflect domain language. - Confusing CQRS with Event Sourcing. You do not need an event store, event bus, or eventual consistency to implement CQRS. A single SQL Server database with separate read and write models in code delivers 80% of the benefits with 20% of the complexity.
- Premature database segregation. Starting with separate read and write databases introduces distributed consistency challenges from day one. Begin with a single database and logically separated models. Physical database separation is an optimization you add when monitoring data proves you need it.
- Skipping the validation pipeline. Without a
ValidationBehaviorin the MediatR pipeline, each handler must validate its own input. This leads to inconsistent validation, duplicated checks, and invalid commands reaching domain logic. Always implement validation as a pipeline behavior.
Frequently Asked Questions
Is CQRS only for microservices?
No. CQRS works equally well in monolithic applications. In fact, it is often easier to implement in a monolith because you share a single database and deployment unit. CQRS is a code-level architectural pattern, not a deployment pattern. A monolithic .NET application with MediatR, separated command and query handlers, and a single SQL Server database is a perfectly valid and common CQRS implementation.
Do I need separate databases for reads and writes?
No. The simplest and most common CQRS implementation uses a single database. You separate models in code: commands go through repository interfaces that enforce aggregate boundaries, while queries hit the DbContext directly with projections. Physical database separation is an optional optimization for extreme scale requirements, not a requirement of the pattern.
How does CQRS work with real-time data?
It depends on your consistency requirements. With a single database, reads are immediately consistent — queries see the latest committed writes. With separated databases, there is a propagation delay between write and read stores. For most applications, this eventual consistency is acceptable (product catalogs, reporting). For financial transactions or inventory where stale reads cause business errors, stick with a single database or implement compensating mechanisms.
Can I use CQRS with Minimal APIs?
Yes, and it is a natural fit. Each Minimal API endpoint maps directly to a single command or query dispatched through MediatR. The endpoint receives the request, constructs the command or query, calls mediator.Send(), and returns the result. This keeps endpoints thin and focused, which aligns perfectly with the Minimal API philosophy.
What is the relationship between CQRS and DDD?
CQRS and Domain-Driven Design are complementary but independent. DDD provides the modeling methodology — aggregates, value objects, bounded contexts. CQRS provides the operational separation — commands modify aggregates, queries read projections. You can use CQRS without DDD (many applications do), and you can use DDD without CQRS. When combined, DDD defines what your domain looks like, and CQRS defines how operations flow through it.
Start Separating What Should Never Have Been Together
CQRS is not a framework to install or a library to reference. It is a structural decision that acknowledges a fundamental truth: the way you read data and the way you change data are different problems that deserve different solutions. With MediatR in .NET 10, the implementation is straightforward. Commands and queries are simple records. Handlers are focused classes with a single responsibility. Pipeline behaviors handle cross-cutting concerns like validation. The entire pattern maps cleanly to Clean Architecture layers that your team already understands.
The practical next step is concrete: pick one endpoint in your current application where the read model and the write model have clearly diverged. Create a command record for the write operation and a query record for the read. Implement their handlers. Wire them through MediatR. You will immediately feel the clarity of having each operation in its own class with its own tests and its own change lifecycle. That single refactoring will tell you more about CQRS than any theoretical discussion ever could.
Once your commands flow through MediatR, add a ValidationBehavior to intercept them with FluentValidation rules. Validation becomes a pipeline concern, not something scattered across handlers. Your handlers stay focused on business logic, and invalid commands never reach them.
Share this post
Subscribe
Get the latest posts delivered right to your inbox.
Leave a comment