← Home ← Codex ← DEBT
Browse by Category
+ added · updated 7d
← Back to glossary

Read Model Projections

Architecture Advanced
debt(d8/e8/b8/t7)
d8 Detectability Operational debt — how invisible misuse is to your safety net

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.

e8 Effort Remediation debt — work required to fix once spotted

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.

b8 Burden Structural debt — long-term weight of choosing wrong

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.

t7 Trap Cognitive debt — how counter-intuitive correct behaviour is

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.

About DEBT scoring →

Also Known As

projection read model CQRS read side event projection

TL;DR

Denormalised views of domain data built by processing event streams — the read side of CQRS, optimised for query performance rather than write consistency.

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

Projections are just database views — views query normalised data at read time; projections denormalise proactively by processing each event as it occurs, making reads O(1) instead of O(joins).

Why It Matters

A read model projection for 'customer order history' stores a pre-computed array on the customer document — the query is a single key lookup instead of joining orders, line items, and products at read time.

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

✗ Vulnerable
// 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
✓ Fixed
// 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

Added 16 Mar 2026
Edited 22 Mar 2026
Views 60
Rate this term
No ratings yet
🤖 AI Guestbook educational data only
| |
Last 30 days
0 pings T 2 pings W 1 ping T 0 pings F 0 pings S 0 pings S 0 pings M 0 pings T 0 pings W 1 ping T 0 pings F 0 pings S 0 pings S 1 ping M 2 pings T 0 pings W 1 ping T 0 pings F 0 pings S 0 pings S 0 pings M 0 pings T 0 pings W 0 pings T 0 pings F 0 pings S 1 ping S 1 ping M 1 ping T 0 pings W
No pings yet today
Google 1
Amazonbot 9 Google 9 Perplexity 7 ChatGPT 5 Ahrefs 4 Scrapy 3 Unknown AI 2 SEMrush 2 Claude 1 Meta AI 1 PetalBot 1
crawler 41 crawler_json 3
DEV INTEL Tools & Severity
🟡 Medium ⚙ Fix effort: High
⚡ Quick Fix
Build a dedicated read model for complex query needs — project domain events into a denormalised table or document tuned exactly for what the UI needs, not what's convenient to store
📦 Applies To
any web cli queue-worker
🔗 Prerequisites
🔍 Detection Hints
Complex JOIN query serving a read use case that could be a denormalised read model; same domain model used for both writes and complex reporting reads
Auto-detectable: ✗ No phpstan deptrac
⚠ Related Problems
🤖 AI Agent
Confidence: Low False Positives: Medium ✗ Manual fix Fix: High Context: File Tests: Update


✓ schema.org compliant