Skip to main content

Command Palette

Search for a command to run...

CQRS in .NET: Separating Reads from Writes Without Overengineering

Updated
4 min readView as Markdown

CQRS gets a reputation as an "enterprise architecture" pattern that's overkill for most applications, and honestly, that reputation is often deserved. I've seen teams adopt full CQRS with separate read and write databases and event sourcing for a system that had maybe five write operations a day. That's not architecture, that's ceremony. But the core idea, separating the model you use to change data from the model you use to read it, has genuinely saved me real complexity on systems where reads and writes have very different shapes.

The problem CQRS actually solves

On a loan management portal I worked on, the write side needed strict validation, business rules, and a normalized data model to keep application, verification, and disbursement processes consistent. The read side needed something completely different: fast, denormalized views for a dashboard showing loan status across thousands of members. Forcing both needs through the same model meant compromises on both sides. Validation logic leaked into read paths that didn't need it. Read performance suffered because the write-optimized schema wasn't shaped for the queries the dashboard actually ran.

CQRS just names that split explicitly instead of pretending one model can serve both jobs well.

What it looks like without going overboard

You don't need a message bus or separate databases to get real value from this. The simplest version is just separating your command and query handling in code:

csharp

public record ApproveLoanCommand(Guid ApplicationId, Guid ApprovedBy);

public class ApproveLoanHandler
{
    public async Task<Result> HandleAsync(ApproveLoanCommand command)
    {
        var application = await _repository.GetByIdAsync(command.ApplicationId);
        application.Approve(command.ApprovedBy);
        await _repository.SaveAsync(application);
        return Result.Success();
    }
}

public record LoanSummaryQuery(Guid MemberId);

public class LoanSummaryHandler
{
    public async Task<LoanSummaryDto> HandleAsync(LoanSummaryQuery query)
    {
        return await _context.Loans
            .Where(l => l.MemberId == query.MemberId)
            .Select(l => new LoanSummaryDto { Id = l.Id, Status = l.Status, Amount = l.Amount })
            .FirstOrDefaultAsync();
    }
}

Commands go through your domain model with full validation and business rules. Queries bypass that entirely and project straight to the shape the UI actually needs. No event sourcing required, no separate database, just an honest acknowledgment that reading and writing are different jobs with different constraints.

When separate read models actually earn their cost

For most applications, that code-level separation is enough. Where I've reached for an actual separate read store, a denormalized reporting database updated asynchronously from the write side, was specifically for reporting and dashboard scenarios where the read load was heavy, the queries were complex joins across many entities, and a few seconds of staleness was completely acceptable.

That last condition matters more than people give it credit for. If your business genuinely cannot tolerate any staleness between a write and the next read, full CQRS with eventual consistency adds a real, hard-to-debug complexity for a use case that doesn't want it.

MediatR, and why I'm careful with it

A lot of .NET CQRS implementations reach for MediatR to route commands and queries to handlers automatically. It's a fine library. My caution is specifically about letting it become an excuse to skip thinking about what actually needs separating. I've seen codebases where every single operation, no matter how trivial, gets wrapped in a command and a handler because "that's the pattern," which just adds indirection without adding any of the benefit CQRS is supposed to provide.

csharp

public class ApproveLoanHandler : IRequestHandler<ApproveLoanCommand, Result>
{
    // same logic, now routed through MediatR
}

Use it when the routing and cross-cutting concerns, like logging or validation pipelines, genuinely earn their place. Don't use it as a substitute for deciding whether CQRS is the right tool at all.

My take on this

I ask one question before reaching for this pattern: do my read and write needs actually conflict? If a single model serves both without meaningful compromise, CQRS is solving a problem I don't have. If validation, performance, and data shape genuinely pull in different directions, the separation pays for itself quickly. The mistake isn't using CQRS. It's using it as a default instead of a decision.