log entry
CQRS in .NET: When It Makes Sense and When It Doesn't
- .NET
- Asp.Net
- Core
- CQRS
Every .NET codebase I've worked on that eventually adopted CQRS arrived there the same way: through a service class that nobody wanted to open anymore.
You know the one. It starts as JobService. It has CreateJob, GetJobById, and UpdateJob. Reasonable. Six months later it has GetJobForAdminDashboard, GetJobForPublicListing, GetJobWithApplicationCounts, ArchiveExpiredJobs, and a constructor taking nine dependencies because one method needs the email sender, another needs the search indexer, and a third needs the payment client. The file is 1,400 lines. Two of the read methods load full EF Core entities with four Include calls when all they need is a title and a salary range. Somebody added a bool sendNotification = true parameter in 2023 and now four call sites pass false.
Nothing here is a disaster. That's exactly the problem. It degrades slowly enough that no single commit looks wrong.
At some point someone says "we should look at CQRS," and the team either adopts it and loves it, or adopts it and spends the next year writing three files for every endpoint while quietly wondering what went wrong. This post is about telling those two outcomes apart before you commit.
What CQRS actually means
Command Query Responsibility Segregation says one thing: the model you use to change state should be separate from the model you use to read state.
That's it. That's the whole pattern.
It's a descendant of Bertrand Meyer's command-query separation, which said a method should either change something or return something, never both. CQRS scales that idea up from methods to models. Writes get their own path through the system. Reads get their own path. They don't share a service class, they usually don't share DTOs, and they don't have to share a data access strategy.
Notice what the definition does not say. It doesn't mention event sourcing. It doesn't mention message buses. It doesn't mention two databases. Those things pair well with CQRS in certain architectures, which is why they show up in the same conference talks, but none of them are part of the pattern. Greg Young, who named it, has spent a good chunk of his career telling people this.
Commands and queries in practice
A command expresses an intent to change something. It's named after a business action, it usually returns nothing or an identifier, and it's allowed to have side effects: writing to the database, publishing a message, sending an email.
public sealed record PublishJobCommand(Guid JobId, Guid RequestedBy);
A query asks a question. It returns data, it has no side effects, and it should be safe to call twice.
public sealed record GetJobDetailsQuery(Guid JobId);
The naming matters more than it looks. PublishJobCommand tells you something UpdateJob(jobDto) never could: that publishing is a distinct business operation with its own rules. When your command names read like sentences from a requirements document, new developers can browse the folder and understand what the system does. I've onboarded people onto CQRS codebases by telling them to read the Features folder, and that worked better than any architecture diagram I've ever drawn.
The asymmetry between the two sides is where the real payoff lives. Writes care about invariants, validation, and consistency, so they want rich domain entities and change tracking. Reads care about shape and speed, so they want flat projections and no tracking overhead. Forcing both through the same repository interface means one of them is always compromising.
Traditional services versus CQRS
Here's the same functionality, two ways.
Layered service approach:
public class JobService : IJobService
{
public Task<JobDto> GetByIdAsync(Guid id);
public Task<PagedResult<JobDto>> SearchAsync(JobSearchFilter filter);
public Task<Guid> CreateAsync(CreateJobDto dto);
public Task PublishAsync(Guid id);
public Task CloseAsync(Guid id, string reason);
// ...and eleven more
}
The controller injects IJobService. So does the background worker. So does the admin module. Every one of them drags in all nine constructor dependencies whether they need them or not. Unit testing CloseAsync means mocking a search indexer it never touches.
CQRS approach:
CreateJobCommand → CreateJobCommandHandler
PublishJobCommand → PublishJobCommandHandler
CloseJobCommand → CloseJobCommandHandler
GetJobDetailsQuery → GetJobDetailsQueryHandler
SearchJobsQuery → SearchJobsQueryHandler
Each handler declares exactly what it needs. The class you open to fix a bug in job publishing contains only job publishing. Merge conflicts drop noticeably, because two developers working on two features are no longer editing the same 1,400-line file.
The cost is honest and immediate: more files. A change that used to be one method is now a command, a handler, a validator, and possibly a DTO. Whether that's organization or bureaucracy depends entirely on how complex the operation actually is.
A realistic project layout
This is roughly the Clean Architecture shape I keep coming back to, organized by feature rather than by technical type:
src/
├── JobBoard.Domain/
│ ├── Jobs/
│ │ ├── Job.cs
│ │ ├── JobStatus.cs
│ │ └── Events/JobPublishedEvent.cs
│ └── Common/Entity.cs
│
├── JobBoard.Application/
│ ├── Jobs/
│ │ ├── Commands/
│ │ │ ├── CreateJob/
│ │ │ │ ├── CreateJobCommand.cs
│ │ │ │ ├── CreateJobCommandHandler.cs
│ │ │ │ └── CreateJobCommandValidator.cs
│ │ │ └── PublishJob/
│ │ └── Queries/
│ │ ├── GetJobDetails/
│ │ └── SearchJobs/
│ ├── Common/Behaviors/ValidationBehavior.cs
│ └── Abstractions/IApplicationDbContext.cs
│
├── JobBoard.Infrastructure/
│ ├── Persistence/ApplicationDbContext.cs
│ └── Messaging/OutboxPublisher.cs
│
└── JobBoard.Api/
└── Endpoints/JobEndpoints.cs
The thing that makes this work is the vertical slicing. Everything about creating a job sits in one folder. Not a Handlers folder, a Validators folder, and a Dtos folder scattered across the project. When you delete a feature, you delete a directory.
Commands and queries, end to end
Here's the write side:
public sealed record CreateJobCommand(
Guid EmployerId,
string Title,
string Description,
string Location,
EmploymentType EmploymentType,
decimal? SalaryMin,
decimal? SalaryMax) : IRequest<Guid>;
internal sealed class CreateJobCommandHandler
: IRequestHandler<CreateJobCommand, Guid>
{
private readonly IApplicationDbContext _db;
private readonly IDateTimeProvider _clock;
public CreateJobCommandHandler(
IApplicationDbContext db,
IDateTimeProvider clock)
{
_db = db;
_clock = clock;
}
public async Task<Guid> Handle(
CreateJobCommand command,
CancellationToken ct)
{
var employer = await _db.Employers
.SingleOrDefaultAsync(e => e.Id == command.EmployerId, ct)
?? throw new NotFoundException(nameof(Employer), command.EmployerId);
if (!employer.CanPostJobs())
throw new SubscriptionRequiredException(employer.Id);
var job = Job.Draft(
employer.Id,
command.Title,
command.Description,
command.Location,
command.EmploymentType,
SalaryRange.Create(command.SalaryMin, command.SalaryMax),
_clock.UtcNow);
_db.Jobs.Add(job);
await _db.SaveChangesAsync(ct);
return job.Id;
}
}
Two dependencies. The business rules live in Job and Employer, where they belong. The handler orchestrates; it doesn't decide.
And the read side:
public sealed record GetJobDetailsQuery(Guid JobId)
: IRequest<JobDetailsResponse?>;
public sealed record JobDetailsResponse(
Guid Id,
string Title,
string CompanyName,
string Location,
string? SalaryDisplay,
int ApplicantCount,
DateTime? PublishedAtUtc);
internal sealed class GetJobDetailsQueryHandler
: IRequestHandler<GetJobDetailsQuery, JobDetailsResponse?>
{
private readonly IApplicationDbContext _db;
public GetJobDetailsQueryHandler(IApplicationDbContext db) => _db = db;
public Task<JobDetailsResponse?> Handle(
GetJobDetailsQuery query,
CancellationToken ct)
{
return _db.Jobs
.AsNoTracking()
.Where(j => j.Id == query.JobId && j.Status == JobStatus.Published)
.Select(j => new JobDetailsResponse(
j.Id,
j.Title,
j.Employer.CompanyName,
j.Location,
j.SalaryRange.Display,
j.Applications.Count(),
j.PublishedAtUtc))
.SingleOrDefaultAsync(ct);
}
}
Look at what the query handler doesn't do. No repository. No domain entity materialization. No AutoMapper. It projects straight into the response shape, so EF Core generates a SQL statement that selects seven columns and a count instead of hydrating an aggregate you were going to throw away.
This is the part people underrate. CQRS gives you permission to stop pretending your reads need the domain model. If a report needs a gnarly window function, you can drop to Dapper in a single query handler without that decision leaking anywhere else:
internal sealed class GetEmployerHiringStatsQueryHandler
: IRequestHandler<GetEmployerHiringStatsQuery, IReadOnlyList<MonthlyStatsRow>>
{
private readonly IDbConnectionFactory _connections;
// ... raw SQL here, and nothing else in the app knows or cares
}
I've done exactly this on reporting endpoints that were timing out under EF Core. It was a fifteen-minute change confined to one file. In the old JobService, the same change would have meant introducing a second data access mechanism into a class that fifteen other things depended on.
When CQRS earns its keep
Complex business logic. If creating a job involves subscription checks, credit deduction, content moderation, and a search index update, that logic deserves a named home. CreateJobCommandHandler is that home.
Large teams and large codebases. Vertical slices mean people can work in parallel without stepping on each other. The bigger the team, the more this matters.
Many distinct use cases. When the same entity is touched by a public API, an admin portal, a bulk importer, and a scheduled worker, each with different rules, separate handlers stop those rules from tangling.
Genuinely different read and write needs. A job board where writes are a trickle and reads are a flood. Reads want denormalization, caching, and full-text search. Writes want transactional integrity. One model can't optimize for both.
Domain-heavy applications. CQRS and DDD fit together naturally. Commands map cleanly onto domain operations, and queries stay out of the domain model's way.
Event-driven and distributed systems. Once you're publishing integration events or running an outbox, having a clear write pipeline that every state change flows through is worth a lot. You have one obvious place to hook in behaviors like transaction management and event dispatch.
When it's the wrong tool
Small CRUD applications. If your create operation is "validate the fields and insert the row," a command handler adds a layer of indirection around a DbContext call. Write the minimal API endpoint and move on.
Simple internal APIs. The admin tool three people use doesn't need pipeline behaviors.
Teams that haven't bought in. CQRS half-applied is worse than not applied. I've seen codebases where "commands" returned full entity graphs and "queries" quietly saved changes. All of the file count, none of the clarity.
When it's cargo cult. If the honest answer to "what problem is this solving?" is "it's what good .NET projects do," you're about to pay a real cost for an imaginary benefit.
In my experience the bad outcomes are rarely caused by teams that were too slow to adopt CQRS. They're caused by teams that adopted it on day one of a project whose requirements they didn't understand yet.
No, you don't need two databases
This myth has done more damage to CQRS's reputation than any legitimate criticism.
Separate read and write models is the pattern. Separate read and write databases is one possible deployment topology, and most applications should never go near it. The moment you split the stores you've bought eventual consistency, replication lag, stale-read bugs, and the fun conversation where a user creates a job, gets redirected to the list page, and doesn't see it.
Every CQRS implementation I'd call successful ran against a single PostgreSQL or SQL Server database. Commands used EF Core with change tracking. Queries used AsNoTracking() projections, sometimes Dapper, occasionally a materialized view. One connection string. Same transaction boundary. You get the code-level separation with none of the distributed systems tax.
Split the database when you have a measured, specific problem that only a read replica or a denormalized store can solve. Not before.
CQRS is not MediatR
This confusion is so common it's worth being blunt: CQRS is a pattern. MediatR is a library. You can do CQRS with neither, and you can use MediatR while doing nothing resembling CQRS.
At its core, CQRS with no library at all looks like this:
app.MapPost("/jobs", async (
CreateJobRequest request,
CreateJobHandler handler,
CancellationToken ct) =>
{
var id = await handler.Handle(request.ToCommand(), ct);
return Results.Created($"/jobs/{id}", new { id });
});
Inject the handler directly. Done. That's CQRS.
What MediatR buys you is the pipeline. Cross-cutting concerns like validation, logging, and transaction scoping get registered once and apply to everything:
public sealed class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
var failures = (await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(request, ct))))
.SelectMany(r => r.Errors)
.Where(f => f is not null)
.ToList();
if (failures.Count > 0)
throw new ValidationException(failures);
return await next();
}
}
That's genuinely useful, and it's why MediatR became the default. Wolverine offers a similar idea with a different flavor, leaning on source generation instead of runtime reflection and bundling messaging and transactional outbox support. Both are implementation aids.
If you find yourself unable to explain your architecture without naming a NuGet package, the package has become the architecture. That's a smell, and it's worth noticing before the library's licensing or maintenance situation changes under you.
Mistakes I keep seeing
Commands that return entities. A command returning the full updated aggregate so the UI can render it collapses the separation you just built. Return an ID. Let the client issue a query.
Handlers doing domain work. If PublishJobCommandHandler contains the rules about when a job may be published, your domain model is anemic and the handler is the new god class, just smaller. Push the rules into Job.Publish().
A handler per database table. CQRS is about use cases. UpdateJobFieldsCommand is a CRUD operation wearing a costume. ExtendJobExpiryCommand and CorrectJobSalaryRange are use cases.
Pointless one-to-one DTO mapping. Commands that mirror your request models field for field, mapped through three layers, for no gain. Sometimes the request model is fine as the command.
Handlers calling handlers. When a command handler sends another command through the mediator, you've built a hidden call graph nobody can follow. Extract a shared domain service or a plain method instead.
Applying it everywhere. You're allowed to have a CQRS core and a few boring endpoints that hit the DbContext directly. Consistency is valuable, but not more valuable than proportionality.
The complexity trade-off
Here's the thing nobody says loudly enough: CQRS doesn't remove complexity. It relocates it.
The complexity in a fat service class is tangled but visible. Everything is in one file. You can read top to bottom and follow it. CQRS untangles that at the cost of distribution. Now understanding a feature means jumping between a command, a handler, a validator, a behavior, and a domain method. Your stack traces get deeper. Find All References stops helping, because the mediator dispatches by type and the connection between the endpoint and the handler is resolved at runtime.
That trade is excellent when the domain is complex, because tangled complexity in a rich domain becomes unmanageable fast. It's a terrible trade when the domain is simple, because you've paid the distribution cost to untangle something that was never tangled.
The question isn't "is CQRS good architecture?" It's "does my application have enough inherent complexity to justify moving it around?" Architecture patterns are answers. Adopting one before you have the question is how projects end up with nine layers of indirection wrapping a CRUD app.
Popularity is not evidence. CQRS is over-represented in blog posts and conference talks relative to how often it's the right call, because "we used a layered service and it was fine" doesn't make a compelling talk.
A rule of thumb
Ask two questions before you introduce CQRS.
One: can I name at least five operations in this system that are more than "save these fields"? Publish a job. Deduct a hiring credit. Reject an application with a reason. Extend an expiry date. Merge duplicate employer accounts. If you can list them easily, you have use cases, and use cases want handlers. If you strain past two, you have CRUD, and CRUD wants a service class.
Two: do my reads and writes actually want different things? Not "might someday." Today. Are you already using AsNoTracking() everywhere on reads? Already tempted to write raw SQL for one report? Already loading four Includes for a page that shows three fields? Those are the symptoms. If your reads are context.Jobs.Find(id) and always will be, one model is enough.
Two yeses: adopt it, you'll be glad in a year. Two noes: skip it, you'll be glad tomorrow. One of each: start with clean service classes, organize by feature instead of by layer, and keep the seam where the split would go. Refactoring a well-organized service into handlers later is a mechanical afternoon. Refactoring premature handlers back into something simpler is a negotiation with everyone who's learned the current structure.
The best architecture decision I've been part of wasn't adding CQRS. It was adding it to the three modules that needed it, leaving the other nine alone, and being able to explain to any new developer exactly why the line sat where it did.
Patterns aren't achievements. They're answers to questions. Make sure you've got the question first.