Playbook
How Does a CQRS (Command Query Responsibility Segregation) Pipeline Work? Anatomy of Command and Query Flows (How CQRS Works Inside Command And Query Pipelines)
What is a CQRS request pipeline? How does an HTTP request travel through Controller, MediatR, pipeline behaviors, a handler, Outbox, and a read model?
What happens when an HTTP request reaches a system? Where does validation run, when does a transaction open, and which layer applies domain rules? In a CQRS system, that journey differs from a classic CRUD architecture. This article follows a request from the API to the database and then to the read model.
For a reader new to CQRS, the shortest glossary is this: a Command expresses intent to change the system; a Query asks for information without changing it; a Handler processes that request. A mediator such as MediatR lets a Controller send a request into the correct flow instead of knowing the handler directly.
POST /orders
↓
Controller
↓
Mediator.Send(command)
↓
Pipeline behaviors
↓
Command handler
↓
Aggregate + Repository
↓
Database
What is a pipeline behavior?
A pipeline behavior is a shared technical control that we do not want to repeat in every command. None of these are business rules; business rules live in the handler and aggregate.
ValidationBehavior
↓
AuthorizationBehavior
↓
LoggingBehavior
↓
MetricsBehavior
↓
RetryBehavior (safe operations only)
↓
TransactionBehavior
↓
Handler
The transaction opens near the end because invalid, unauthorized, or otherwise rejectable requests should not hold database connections and locks. By the time the handler is reached, the system is ready to apply the business decision atomically.
An aggregate protects the business rules that must never be broken—its invariants. For example, if a completed order may not be cancelled again, that rule belongs in the Order aggregate, not in a Controller.
The query side follows a different route. For the same order, a user may need not the full domain object, but a concise view shaped for the screen:
GET /orders
↓
Authorization
↓
Cache (when appropriate)
↓
Read database / Search index
↓
OrderSummary DTO
↓
Frontend
Concepts at first use
📦 Handler
Runs the application workflow for one command or query.
📦 Mediator
Lets a Controller send a request to the right handler without knowing it directly.
📦 Pipeline Behavior
A shared technical layer for every request, such as validation, authorization, or logging.
📦 Repository
The application boundary that loads and persistently stores an aggregate.
📦 DTO
Carries not the entire domain model, but the data a screen needs.
Every term has a motivation. Idempotency prevents a second order when the same request arrives twice. Outbox makes a database write and event publication safe within the same transaction boundary. They are not technical decoration added to a pipeline; they answer production failure scenarios.
Keep this distinction in mind when reading the handler example: the handler applies only the business decision. Validation, authorization, and logging are not inside it; pipeline behaviors handle them. The handler stays small and every request crosses the same security and observability rules.
CQRS is often reduced to two folders, a few MediatR handlers, and perhaps a cache. That picture is misleading. CQRS is not primarily about splitting code. It separates decisions that change a system's state from requests that ask for information about that state.
This is the second chapter of CQRS- Anatomy of Decisions. The first chapter asks why separation becomes necessary. Here we enter the mechanism: which gates a command passes, why a query should not take the same route, and when a read model becomes a separate system.
One request, two different intentions
PlaceOrder is a command. It wants to introduce a new fact into the system. It runs rules, requires authority, opens a transaction, and must be auditable.
GetOrderSummary is a query. It creates no new fact. It wants a narrow, fast view shaped for a screen. It does not need to load the aggregate, execute domain rules, or contend with write-side locks.
That distinction has an algorithmic consequence. Command-side cost is deliberately accepted for validation and consistency. Query-side cost should grow with the rows and fields needed by the view, not with the entire domain graph.
The command pipeline: a safe path for decisions
A command side is not merely a handler invocation. Before a decision reaches business logic, it should pass through shared controls.
API
→ Authentication / Authorization
→ Validation
→ Idempotency check
→ Transaction
→ Command Handler
→ Aggregate + Domain Rules
→ Persist + Outbox
→ Commit
Pseudocode:
handle(command):
authorize(command.actor)
validate(command)
return idempotency.execute(command.key):
begin transaction
aggregate = repository.load(command.aggregateId)
aggregate.apply(command)
repository.save(aggregate)
outbox.store(aggregate.domainEvents)
commit transaction
Each step has one responsibility. Validation rejects an invalid shape early; it does not replace a business rule. Authorization checks whether the caller may make the decision. The aggregate protects invariants. The Outbox makes the persisted state and the event to be published part of the same transaction boundary.
Mediator makes this flow practical in C#:
public sealed record PlaceOrder(Guid CustomerId, IReadOnlyList<OrderLine> Lines) : IRequest<OrderId>;
public sealed class PlaceOrderHandler : IRequestHandler<PlaceOrder, OrderId>
{
public async Task<OrderId> Handle(PlaceOrder command, CancellationToken ct)
{
var order = Order.Place(command.CustomerId, command.Lines);
await repository.AddAsync(order, ct);
await outbox.AddAsync(order.DomainEvents, ct);
return order.Id;
}
}
The handler stays small because audit, timing, validation, and retry policies live in pipeline behaviors. The benefit is that every handler does not reproduce the same ceremony. The cost is an extra call path, so behavior order should be documented and observable.
The query pipeline: read only what the user needs
A query starts with a different question: which information does the user need, within which latency budget? The answer is a use case, not the domain model.
API
→ View authorization
→ Query Handler
→ Read model / cache / search index
→ DTO shaped for the screen
Loading an Order aggregate, customer relationships, and stock rules for an order list is usually unnecessary. A query handler selects exactly the fields required by the view. Its cost is therefore bounded by the returned rows and columns instead of an entire object graph.
public sealed record GetOrderSummary(Guid OrderId) : IRequest<OrderSummaryDto?>;
public sealed class GetOrderSummaryHandler : IRequestHandler<GetOrderSummary, OrderSummaryDto?>
{
public Task<OrderSummaryDto?> Handle(GetOrderSummary query, CancellationToken ct) =>
readDb.OrderSummaries
.Where(x => x.Id == query.OrderId)
.Select(x => new OrderSummaryDto(x.Id, x.Status, x.Total, x.UpdatedAt))
.SingleOrDefaultAsync(ct);
}
A DTO is not decoration. It is the read contract. The read model can change with a screen; the command model can change with business rules. Those changes do not have to move at the same speed.
Logical and physical CQRS
For most teams, logical CQRS is the first useful step: commands and queries are separated in code but share one relational database. ACID transactions remain simple, operating cost stays low, and debugging is direct.
Physical CQRS moves a read model to a separate data store, cache, or search index. It gives the read side independent scaling, but introduces freshness expectations, projection failures, and rebuild procedures. Physical separation is not a performance aesthetic; it is an answer to a demonstrated constraint.
| Choice | Gain | Accepted cost |
|---|---|---|
| Logical CQRS | Low operational cost, strong consistency | Reads and writes share infrastructure |
| Physical CQRS | Independent scaling, view-specific models | Eventual consistency and projection operations |
From write model to read model: CDC or Outbox?
With physical separation, the decisive question is how data moves. Change Data Capture can observe database logs and is useful when replaying existing changes without modifying the application. Its trade-off is tighter coupling between a database schema and a message contract.
The Outbox pattern writes a domain event to an outbox table in the same transaction as the state change. A relay publishes it to a broker; consumers process it idempotently. This makes the database write succeeded, event publish failed gap manageable. In exchange, relay health, retries, dead letters, and consumer idempotency become part of the design.
Command commit
→ Outbox record
→ Relay publishes event
→ Projection consumes event
→ Read model updates
The realistic goal is not a magical exactly-once guarantee. It is at-least-once delivery paired with idempotent processing. A projection can record an event identifier so that a duplicate event does not change the view twice.
Why naive solutions break
- Publishing directly to a broker from a handler: a transaction can roll back after the message has already been consumed.
- Loading aggregates for every query: list screens become expensive through avoidable rules, joins, and I/O.
- Treating every read-model delay as a defect: some views need fresh data; others can tolerate seconds of lag. That is a product decision.
- Solving every problem with physical CQRS: another data store is not just technology; it is a permanent operational responsibility.
Decision checklist
- Are command success criteria and idempotency keys explicit?
- Is every invariant protected at the aggregate boundary?
- Is each query shaped around its use-case DTO rather than the domain graph?
- Is read-model freshness expressed as a product expectation?
- Can a projection be rebuilt, observed, and verified safely?
CQRS is not an automatic ticket to unlimited scale. Used well, it turns decisions into a deliberate pipeline and reads into models fit for a real need. The system becomes more visible and more resilient to change.
The next chapter crosses the service boundary: CQRS with brokers, projections, and recovery strategies in distributed systems.
What is a read model?
A read model is not a copy of the write-side domain model. It is a view prepared for a screen's need.
Orders (write model)
Id | CustomerId | Status | Lines | Rules
↓ Projection
OrderSummary (read model)
Id | CustomerName | Status | Total | BadgeColor
CDC and Outbox compared
| Criterion | CDC | Outbox |
|---|---|---|
| Domain-event intent | Indirect | Explicit |
| Database-schema coupling | High | Lower |
| Replay | Strong | Strong |
| Control of event payload | Limited | Full |
| Microservice communication | Context-dependent | Very suitable |
Why naive solutions fail in production
Handler
→ DbContext.SaveChanges()
→ Message publish
If the second step fails, the data is written but the event is lost. Or a transaction may roll back after this call has already caused an external side effect:
await emailService.Send(...)
The Outbox therefore puts the persisted change and event-to-publish inside one transaction boundary. Side effects such as email are handled after commit by retry-safe consumers.
How does a real request flow?
POST /orders
↓
Controller
↓
Mediator.Send()
↓
Validation → Authorization → Logging → Metrics → Transaction
↓
Handler
↓
Aggregate
↓
Repository + Outbox
↓
Commit
↓
Relay → Broker / Kafka
↓
Projection
↓
Read database
↓
GET /orders → Query handler → DTO → Frontend
CQRS is not automatic scale. But if you can explain why every step in this pipeline exists, it creates deliberate separation of responsibility. If you cannot, you may not be ready to use CQRS yet.
Why should I know this flow?
When you know which layers a request crosses, you can:
- Avoid putting validation randomly in a Controller or handler.
- Keep business rules out of the HTTP layer and protect them at the aggregate boundary.
- Avoid opening a transaction earlier than necessary.
- Keep handlers free from logging, authorization, and metrics code.
- Move cross-cutting concerns into the pipeline.
The query-side distinction is equally practical:
Order detail
→ Order aggregate
→ the right model for rules and behavior
Order list
→ OrderSummaryDto
→ a narrow, fast view for the screen
Seeing this distinction lets you use CQRS not as a folder convention, but as a discipline for putting each responsibility in the right place.
FAQ
Frequently asked questions
What is Handler?
Runs the application workflow for one command or query.
What is Mediator?
Lets a Controller send a request to the right handler without knowing it directly.
What is "How Does a CQRS (Command Query Responsibility Segregation) Pipeline Work? Anatomy of Command and Query Flows" about?
What is a CQRS request pipeline? How does an HTTP request travel through Controller, MediatR, pipeline behaviors, a handler, Outbox, and a read model?
Engineering Principles Learned
- CQRS is not two databases; it is accepting that reads and writes have different responsibilities.
- Pipelines remove repeated controls from handlers and leave the business decision visible.
- Physical separation is valuable only when freshness, retries, and latency are designed as product decisions.
Continue reading
Continue reading
Related articles
CQRS (Command Query Responsibility Segregation) in Production: Consistency, Failure, and Recovery
How does CQRS stay safe in production? Consistency lag, duplicate events, reordering, projection recovery, retries, DLQs, and Saga strategies.
Related articles
CQRS (Command Query Responsibility Segregation) in Distributed Systems: Events, Brokers, and Projections
How does CQRS work in distributed systems? A practical guide to domain events, message brokers, projections, Outbox, idempotency, and eventual consistency.
Related articles
How Does a Vertical Slice Work from the Inside?
A technical guide to the inner flow of a Vertical Slice: request, validation, handler, aggregate, outbox, projection, idempotency, performance, tests, and…