A typical CQRS slide often draws two columns:

  • Command: changes state, applies rules, validates, and can be expensive.
  • Query: does not change state, returns data quickly, and avoids write-side business logic.

The general idea is right, but taken literally it can create several misconceptions.

A command does not have to be heavy. A query does not mean “no rules.” CQRS does not require two databases. It also does not imply Event Sourcing, microservices, or a message bus.

The important separation is much simpler:

a command expresses an intention to change the system; a query describes information we want to obtain without changing it.

That principle lets each side be designed for a different goal.

The problem CQRS tries to solve

In a small CRUD application, using the same model for reads and writes is often perfectly reasonable.

For example, an Order entity might be used to:

  • create an order;
  • change its status;
  • add products;
  • calculate totals;
  • populate an orders table;
  • build a dashboard;
  • export a report.

The problem appears when those needs start to diverge.

The write model needs to protect invariants and express behavior. The read model wants simple projections, efficient joins, filters, aggregations, and DTOs shaped exactly for each screen.

A single abstraction then starts receiving opposing forces.

CQRS proposes separating those responsibilities.

Command: an intention to change something

A command should describe a business action:

public sealed record CreateOrderCommand(
    Guid CustomerId,
    IReadOnlyList<CreateOrderItem> Items);

It does not say “update these columns.” It says “create an order.”

The handler can then coordinate the use case:

public sealed class CreateOrderHandler
{
    private readonly AppDbContext _db;

    public CreateOrderHandler(AppDbContext db)
    {
        _db = db;
    }

    public async Task<Guid> Handle(
        CreateOrderCommand command,
        CancellationToken cancellationToken)
    {
        var customerExists = await _db.Customers
            .AnyAsync(x => x.Id == command.CustomerId, cancellationToken);

        if (!customerExists)
            throw new InvalidOperationException("Customer does not exist.");

        var order = Order.Create(
            command.CustomerId,
            command.Items.Select(x => new OrderItemInput(
                x.ProductId,
                x.Quantity)));

        _db.Orders.Add(order);
        await _db.SaveChangesAsync(cancellationToken);

        return order.Id;
    }
}

This is where it makes sense to find:

  • input validation;
  • authorization to modify;
  • business rules;
  • aggregate invariants;
  • transaction control;
  • idempotency;
  • event publication;
  • persistence.

The important part is not that the command is “heavy.” The important part is that the write path protects the validity of state.

A trivial command can also be completely valid.

public sealed record RenameTagCommand(
    Guid TagId,
    string Name);

CQRS does not require complexity. It separates it when complexity exists.

Query: obtain information without changing the system

A query models a question:

public sealed record GetOrderDetailsQuery(Guid OrderId);

Its handler can be optimized directly for what the consumer needs:

public sealed class GetOrderDetailsHandler
{
    private readonly AppDbContext _db;

    public GetOrderDetailsHandler(AppDbContext db)
    {
        _db = db;
    }

    public Task<OrderDetailsDto?> Handle(
        GetOrderDetailsQuery query,
        CancellationToken cancellationToken)
    {
        return _db.Orders
            .AsNoTracking()
            .Where(x => x.Id == query.OrderId)
            .Select(x => new OrderDetailsDto(
                x.Id,
                x.Customer.Name,
                x.CreatedAt,
                x.Status,
                x.Items.Sum(i => i.Quantity * i.UnitPrice)))
            .SingleOrDefaultAsync(cancellationToken);
    }
}

Notice what we do not need to do:

  • load the full aggregate;
  • execute domain methods;
  • reconstruct objects that will immediately be mapped to DTOs;
  • validate invariants related to a modification that will not happen.

The query can project directly from the database.

That can make the read path dramatically simpler.

“No rule checking” is a dangerous simplification

Some CQRS explanations say that queries do not run rules.

That needs context.

A query normally does not need write-side invariants, but it may still need:

  • authentication;
  • authorization;
  • tenant isolation;
  • security filters;
  • visibility rules;
  • data masking;
  • pagination limits;
  • privacy controls.

For example:

if (!authorization.CanReadOrder(user, query.OrderId))
    throw new ForbiddenException();

That is still completely compatible with CQRS.

A useful rule is:

a query should not change state or execute logic whose purpose is to decide whether a domain change is valid.

Security and read policies still matter.

Two models does not mean two databases

This is probably the most common misconception.

You can start CQRS with exactly the same database:

                 Application
                     |
          +----------+----------+
          |                     |
       Commands                Queries
          |                     |
     Write model             Read model
          |                     |
          +----------+----------+
                     |
                 PostgreSQL

At first, the separation may exist only in code:

  • different handlers;
  • different models;
  • different DTOs;
  • different routes.

That already provides much of the conceptual benefit.

If read load grows dramatically, you can later evolve toward:

Commands -> Write DB
               |
             events
               |
               v
           Read model -> Read DB

But that is an infrastructure decision, not the definition of CQRS.

Microsoft describes CQRS as separating read and write operations into distinct models and notes that some implementations also physically separate the data stores.

CQRS does not mean Event Sourcing either

CQRS and Event Sourcing are often combined because they fit together well, but they solve different problems.

CQRS asks:

should reads and writes be modeled separately?

Event Sourcing asks:

should changes be persisted as a sequence of events instead of storing only the current state?

You can have:

CQRS + a normal relational database
CQRS + Event Sourcing
CRUD + a relational database

There is no requirement to adopt both patterns together.

In fact, introducing Event Sourcing only because you already have commands and queries can add substantial operational complexity without adding value.

The biggest benefit: models optimized for different goals

Suppose the domain model for an order is:

Order
 ├─ Customer
 ├─ Items
 │   └─ Product
 ├─ Payments
 └─ ShippingAddress

For modifications, that structure may be useful because it represents relationships, behavior, and invariants.

But an administrative table may only need:

public sealed record OrderRow(
    Guid Id,
    string Customer,
    decimal Total,
    string Status,
    DateTime CreatedAt);

The query can produce exactly that object.

You do not need to make the read model a copy of the write model.

This is one of the most powerful ideas in CQRS:

the optimal model for changing the system does not have to be the optimal model for observing it.

A minimal CQRS architecture in .NET

An application can start with something as small as:

Application/
  Orders/
    Commands/
      CreateOrder/
        CreateOrderCommand.cs
        CreateOrderHandler.cs

    Queries/
      GetOrderDetails/
        GetOrderDetailsQuery.cs
        GetOrderDetailsHandler.cs

You do not need to install a library to “have CQRS.”

MediatR or similar tools can help with dispatching, pipelines, and cross-cutting concerns, but the pattern does not depend on them.

You can even call handlers directly from endpoints.

CQRS is a design decision, not a NuGet dependency.

Should a command return data?

The strict rule “commands never return anything” is not especially useful either.

It is reasonable for a command to return information needed to continue the flow, for example:

Guid orderId = await handler.Handle(command, ct);

or even:

CreateOrderResult result = await handler.Handle(command, ct);

The important thing is not to turn the command handler into a second complex query that rebuilds a huge read view after changing state.

A common pattern is:

POST /orders
     |
     v
CreateOrderCommand
     |
     v
returns OrderId
     |
     v
GET /orders/{id}

That keeps the separation clear.

Eventual consistency: only when you physically separate the models

If command and query use the same database within the same transaction boundary, a subsequent read can observe the change immediately.

If you introduce a separate read model updated through events:

Write DB
   |
 OrderCreated
   |
   v
Projection handler
   |
   v
Read DB

there is now a window where the write model has changed but the read model has not caught up yet.

That is eventual consistency.

It is not an accidental defect. It is a property the application must explicitly design for.

It may require:

  • processing indicators;
  • retries;
  • idempotency;
  • versioning;
  • projection monitoring;
  • repair strategies.

That is why moving from logical CQRS to distributed CQRS should not happen automatically.

When CQRS tends to add value

CQRS starts to make sense when there is a real difference between reads and writes:

  • a domain with meaningful rules and invariants;
  • screens whose queries differ substantially from the domain model;
  • far more reads than writes;
  • different scaling needs;
  • dashboards or reports with complex aggregations;
  • multiple representations of the same data;
  • task-oriented workflows rather than generic CRUD.

In those cases, separation can reduce coupling and let each side evolve independently.

When you probably do not need it

For a small CRUD system:

Create
Read
Update
Delete

with simple rules and straightforward queries, adding:

  • command classes;
  • query classes;
  • handlers;
  • buses;
  • events;
  • projections;
  • two stores;
  • eventual consistency;

can create more architecture than domain.

CQRS should not be used as a folder structure that everyone copies.

It should respond to a real asymmetry in the system.

A reasonable progression

Instead of jumping straight to the most complex version, you can evolve in stages.

Level 1: separate operations

Commands
Queries
Same DbContext
Same database

Level 2: separate models

Domain model for writes
DTO/projections for reads
Same database

Level 3: optimize reads

Read replicas
cache
materialized views
specialized indexes

Level 4: separate storage

Write DB
Read DB
events/projections
eventual consistency

Each level should appear because a concrete need exists.

The mental model worth keeping

If an operation answers:

what do you want to change?

it is probably a command.

If it answers:

what do you want to know?

it is probably a query.

And if a query starts changing state, or a command becomes a huge presentation query, responsibilities are probably being mixed again.

CQRS is not about duplicating architecture.

It is about allowing reads and writes to evolve around different problems.

References