Blog / · 6 min read
CQRS: what it is for, when to use it, and when to avoid it
CQRS separates writes and reads. It is not magic architecture, and it does not require Event Sourcing. Here are the real use cases, costs, traps, and a pragmatic Symfony Messenger implementation.
CQRS is often presented as senior architecture.
In many projects, it is just an expensive way to do CRUD. Teams add commands, queries, handlers, buses, sometimes two databases, and then spend more time navigating files than delivering behavior.
But the pattern is not bad. It is often misplaced. CQRS becomes useful when the write model and read model no longer want the same thing.
The question is not “is CQRS clean?”. The question is: have my reads and writes actually diverged?
Simple definition
CQRS means Command Query Responsibility Segregation.
You separate two responsibilities:
- command: an intention that changes system state;
- query: a read request that changes nothing.
final readonly class PlaceOrderCommand
{
public function __construct(
public string $customerId,
public array $lines,
) {}
}
final readonly class GetOrderSummaryQuery
{
public function __construct(public string $orderId) {}
}
The command protects invariants. The query optimizes reads. Both can use the same database at first. CQRS does not mean two databases, Event Sourcing, Kafka, or microservices.
Do not mix CQS, CQRS, and Event Sourcing
Command Query Separation: a method either changes state or returns data, but not both.
CQRS: you apply that separation at application level. Writes and reads go through separate models and paths.
Event Sourcing: you store changes as events and rebuild state from history.
You can use CQRS without Event Sourcing. In most web applications, that is the pragmatic choice.
The real problem CQRS solves
In classic CRUD, the same model often handles reads and writes.
An Order entity validates checkout, calculates totals, persists lines, displays customer history, powers admin dashboards, and feeds accounting exports.
At first it works. Then needs diverge:
- writes want strict invariants;
- reads want fast denormalized views;
- admin needs filtering and aggregation;
- customer UI wants compact responses;
- reporting wants historical data.
CQRS says: stop forcing one model to serve opposite needs.
Example: checkout
Write side:
final readonly class PlaceOrderHandler
{
public function __construct(
private OrderRepository $orders,
private StockGateway $stock,
private PaymentGateway $payments,
) {}
public function __invoke(PlaceOrderCommand $command): OrderId
{
$order = Order::place($command->customerId, $command->lines);
$this->orders->save($order);
$this->stock->reserve($order->id());
$this->payments->authorize($order->id());
return $order->id();
}
}
Read side:
final readonly class GetOrderSummaryHandler
{
public function __construct(private Connection $connection) {}
public function __invoke(GetOrderSummaryQuery $query): OrderSummaryView
{
$row = $this->connection->fetchAssociative(
<<<'SQL'
SELECT o.id, o.status, o.total, c.email
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.id = :id
SQL,
['id' => $query->orderId],
);
if ($row === false) {
throw new OrderSummaryNotFound($query->orderId);
}
return OrderSummaryView::fromRow($row);
}
}
The write handler works with the domain. The read handler works with a projection shaped for the screen.
In a critical system, stock and payment calls should not be a naive synchronous chain after save(). Make them idempotent, or move them to an outbox/after-commit message. This snippet shows read/write separation, not full distributed workflow choreography.
Symfony Messenger implementation
Symfony Messenger documents multiple buses for commands, queries, and events. The important detail: add a bus only if it has different behavior.
framework:
messenger:
default_bus: command.bus
buses:
command.bus:
middleware:
- doctrine_transaction
query.bus: ~
event.bus:
default_middleware: allow_no_handlers
#[AsMessageHandler(bus: 'command.bus')]
final readonly class PlaceOrderHandler
{
public function __invoke(PlaceOrderCommand $command): OrderId
{
// write-side workflow
}
}
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetOrderSummaryHandler
{
public function __invoke(GetOrderSummaryQuery $query): OrderSummaryView
{
// read-side projection
}
}
The command bus can be transactional. The query bus stays light. The event bus can notify several listeners.
Messenger detail: MessageBusInterface::dispatch() returns an Envelope, not the handler value. For a synchronous query bus that must return a view, use HandleTrait or a small application bus that hides that detail.
use Symfony\Component\Messenger\HandleTrait;
use Symfony\Component\Messenger\MessageBusInterface;
final class QueryBus
{
use HandleTrait;
public function __construct(MessageBusInterface $queryBus)
{
$this->messageBus = $queryBus;
}
public function ask(object $query): mixed
{
return $this->handle($query);
}
}
Returning an identifier from a command can be pragmatic in a synchronous flow. If the command is asynchronous, do not promise an immediate return value: return 202 Accepted, a tracking identifier, or let the state be read through a query.
Laravel does not need a bus to apply CQRS
You can start with separated actions:
final readonly class PlaceOrderAction
{
public function execute(PlaceOrderCommand $command): OrderId
{
// write-side workflow
}
}
final readonly class GetOrderSummaryQueryHandler
{
public function handle(string $orderId): OrderSummaryView
{
// read-side projection
}
}
The pattern does not depend on a tool. It depends on separating write intent from read model.
When CQRS is justified
CQRS is useful when:
- read and write models diverge;
- reads need specific performance optimizations;
- writes contain real business invariants;
- several interfaces trigger the same use case;
- async processing is needed and the failure model is understood.
It is especially useful for catalogs, dashboards, order histories, billing, stock, reporting, and domains where reads are frequent but writes must stay strict.
When CQRS is a bad idea
Avoid it when:
- the module is simple CRUD;
- one structure is enough for form, persistence, and display;
- the team cannot maintain the abstractions;
- handlers are not tested;
- nobody owns projections;
- eventual consistency would hurt the product;
- the only reason is “clean architecture”.
Martin Fowler’s warning is still the right one: many systems fit CRUD well, and CQRS is a significant mental leap.
Hidden cost: consistency and duplication
Separate models often mean duplicated data or transformations.
You must answer:
- who updates the read model?
- what does the user see before projection catches up?
- how do you rebuild a broken projection?
- how do you test command and read model together?
- where does filtering logic live?
- what monitoring catches projection delay?
If you do not need a separate projection, do not create that problem.
Progressive CQRS
Level 1: logical separation.
CreateInvoiceCommandCreateInvoiceHandlerGetInvoiceSummaryQueryGetInvoiceSummaryHandler
Level 2: separate buses and middleware.
Commands with transactions, queries without, events with several handlers.
Level 3: physically separate models.
Read database, projection table, search index, materialized cache.
Start at level 1. Move up only when the pain is real.
Decision checklist
Before introducing CQRS, ask:
- Does the read model diverge from the write model?
- Do reads have specific performance needs?
- Does the write side protect important business invariants?
- Will the module last long enough to amortize the abstraction?
- Can the team test isolated handlers?
- Is eventual consistency acceptable if projections arrive?
- Would a simple service layer be enough?
If you have fewer than three strong yes answers, stay simple.
Sources
Keep reading