Read Model Projections
debt(d8/e8/b8/t7)
Closest to 'silent in production until users hit it' (d9), nudged to d8 because phpstan/deptrac can flag some architectural boundary violations, but missing projections manifest as slow queries or stale reads discovered in production.
Closest to 'architectural rework' (e7-e9), scored e8 because introducing projections requires event infrastructure, projection workers, rebuild tooling, and reshaping how reads flow — not a one-component refactor per quick_fix's 'dedicated read model' guidance.
Closest to 'strong gravitational pull' (b7), nudged to b8 because applies_to spans web/cli/queue-worker and projections shape how every read use case is served; once adopted, eventual consistency and replay considerations affect every feature.
Closest to 'serious trap' (t7) per misconception — developers conflate projections with database views, missing that projections are proactively denormalised via event processing; also the idempotency/replay gotcha in common_mistakes contradicts intuitions from CRUD systems.
Also Known As
TL;DR
Explanation
In CQRS/Event Sourcing, the write model stores domain events. Projections listen to those events and build optimised read models — denormalised, pre-computed, shaped exactly for query needs. A dashboard projection might aggregate order totals per customer; a search projection might build Elasticsearch documents; a reporting projection might maintain monthly revenue. Projections can be rebuilt by replaying all events. Each projection is independent — one event can feed many projections without coupling.
Diagram
flowchart LR
CMD[Command] --> AGG[Aggregate]
AGG --> STORE[(Event Store<br/>OrderPlaced<br/>PaymentReceived)]
STORE -->|project| P1[CustomerStats<br/>projection]
STORE -->|project| P2[DashboardSummary<br/>projection]
STORE -->|project| P3[SearchIndex<br/>projection]
P1 --> R1[(Redis<br/>O of 1 lookup)]
P2 --> R2[(Postgres<br/>pre-aggregated)]
P3 --> R3[(Elasticsearch)]
style STORE fill:#6e40c9,color:#fff
style R1 fill:#238636,color:#fff
style R2 fill:#238636,color:#fff
style R3 fill:#1f6feb,color:#fff
Common Misconception
Why It Matters
Common Mistakes
- Projections that query the write model — projections should be fed by events, not queries.
- Not making projections idempotent — event replays must produce the same result; use event IDs to detect re-processing.
- One monolithic projection — separate projections for each use case, each independently rebuildable.
- Not handling projection failures — failed projections cause stale read models; monitor projection lag.
Code Examples
// Slow read — joins at query time:
SELECT c.name, SUM(o.total) as lifetime_value,
COUNT(o.id) as order_count,
MAX(o.created_at) as last_order
FROM customers c
JOIN orders o ON o.customer_id = c.id
GROUP BY c.id;
-- Full table scan + aggregation on every dashboard load
// Projection updated on each OrderPlaced event:
class CustomerStatsProjection {
public function onOrderPlaced(OrderPlaced $event): void {
$stats = $this->store->find($event->customerId) ?? [
'order_count' => 0, 'lifetime_value' => 0, 'last_order' => null
];
$stats['order_count']++;
$stats['lifetime_value'] += $event->total;
$stats['last_order'] = $event->occurredAt;
$this->store->save($event->customerId, $stats);
}
}
// Dashboard query: single key lookup — O(1) regardless of order count