Skip to content
Architecture

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.

4 min read Updated Sep 3, 2026
CQRS in Practice — the Afternoon Version, No Event Sourcing Required

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.

Commands hit the write model; queries hit a separate read model shaped for the screen

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.

Keep reading

Related articles

Architecture 4 min read

Clean Architecture Meets a Real Deadline

The one load-bearing idea under all the concentric circles, the interface-with-one-implementation tax, and the three-folder dose that keeps domains pure without nine-file one-line changes.