CQRS in Practice — the Afternoon Version, No Event Sourcing Required
Split the write model from screen-shaped read queries without new infrastructure: honest SQL read classes, denormalized read tables via events, and the symptoms that tell you when to bother.
CQRS has a branding problem. Say the acronym and people picture event sourcing, projections, sagas, and a sixteen-week rewrite. But the actual idea — Command Query Responsibility Segregation — is almost embarrassingly modest: the code that changes things and the code that reads things have different needs, so stop forcing them through the same model. You can adopt that in an afternoon, in a Laravel or .NET monolith, with zero new infrastructure. That afternoon version is what this post is about.
The problem CQRS actually solves
Watch what happens to a "clean" domain model under real product pressure. The Order aggregate starts pure: it validates, enforces invariants, guards state transitions. Then the dashboard team needs "orders with customer name, item count, total refunds, and last shipment status" — and someone bolts four relations and a computed property onto the aggregate. Then the export needs six more. Two years later your write model is 80% read-serving barnacles, every query eager-loads half the schema, and changing a business rule means wading through display logic.
The diagnosis: writes want normalization, invariants and identity; reads want denormalized, screen-shaped data. One model can't serve both masters well. So — split them.
The afternoon version
Commands go through your rich model exactly as before. Queries skip it entirely and go straight to SQL shaped like the screen:
// WRITE SIDE — the domain model, invariants intact, no display concerns
final class CancelOrderHandler
{
public function handle(CancelOrder $command): void
{
$order = $this->orders->findOrFail($command->orderId); // Eloquent, aggregates, the works
$order->cancel($command->reason); // business rules live HERE
$this->orders->save($order);
}
}
// READ SIDE — no Eloquent hydration, no aggregate, just the screen's data
final class OrderListQuery
{
public function forMerchant(string $merchantId, OrderFilters $f): Collection
{
return DB::table('orders as o')
->join('customers as c', 'c.id', '=', 'o.customer_id')
->leftJoin('shipments as s', 's.order_id', '=', 'o.id')
->where('o.merchant_id', $merchantId)
->when($f->status, fn ($q) => $q->where('o.status', $f->status))
->select([
'o.id', 'o.number', 'o.total_minor', 'o.status',
'c.name as customer_name',
's.tracking_code',
])
->orderByDesc('o.placed_at')
->paginate(25);
}
}
That's it. That's CQRS. The write side stays a fortress of business rules; the read side is honest SQL that returns exactly what the screen renders, in one query, with no N+1 landmines. Read classes are trivially testable (input: filters, output: rows) and trivially optimizable (add an index, reshape the join) without touching a single business rule.
The next notch: denormalized read tables
When the joins get heavy — dashboards aggregating five tables at p95-hostile latency — take one more step: maintain a read table updated by listeners on your domain events:
// order_summaries: one flat row per order, screen-shaped
final class ProjectOrderSummary
{
public function handle(OrderPlaced|OrderShipped|OrderRefunded $event): void
{
DB::table('order_summaries')->updateOrInsert(
['order_id' => $event->orderId],
$this->summarize($event->orderId), // recompute the flat row
);
}
}
Now the dashboard reads one indexed table. The projection is rebuildable from source tables at any time (write that rebuild command on day one — it's your safety net when a projector bug ships). This is still same-database, same-deployable, same-transaction-capable. You've adopted 80% of CQRS's value at roughly 5% of the ceremony.
What I deliberately did not tell you to do
- Event sourcing. Storing state as an event log is a separate decision with separate (heavy) costs — replay infrastructure, versioned upcasting, snapshotting. Some domains genuinely want it (ledgers, audit-critical workflows). Most don't, and CQRS works fine over boring UPDATE-in-place tables. Conflating the two is how sixteen-week rewrites happen.
- Separate databases for reads. Read replicas or a dedicated store (even Elasticsearch as a projection target for search screens) are legitimate later moves, driven by measured load — not the entry fee.
- Command buses with seventeen middleware. A handler class invoked by a controller is a command handler. The bus is optional garnish.
When to bother
Reach for the split when you see the symptoms: aggregates sprouting query-only relations, list endpoints eager-loading half the schema, "add a column to this screen" tickets requiring domain-model surgery, or read latency dominated by ORM hydration. Skip it for simple CRUD — a form that saves what it shows has no read/write divergence to segregate, and the extra classes are pure ceremony. Like every pattern worth keeping, CQRS is a response to a pressure, not a lifestyle.
Buried in a model that serves five screens and no business rule cleanly? Untangling exactly that — without the rewrite — is a very satisfying week of consulting.