Building Read Models with Projections
You have an event stream with thousands of OrderPlaced, OrderShipped, and OrderDelivered events. But your dashboard needs to show "orders by region this month" and your API needs to return "order details by ID". How do you serve these different queries efficiently?
Projections transform your event stream into purpose-built read models.What is a Projection?
A projection is a function that:
- Subscribes to events from the event stream
- Transforms each event into updates for a read model
- Maintains a denormalized, query-optimized view
Event Stream Projections Read Models
โโโโโโโโโโโโโ โโโโโโโโโโโ โโโโโโโโโโโ
โ OrderPlaced โ
โ PaymentReceivedโโโโโโโโโโโโโโโถ OrderDetails โโโโโโโโโโโโโถ [Order lookup by ID]
โ OrderShipped โ Projection
โ OrderDelivered โ
โ ... โโโโโโโโโโโโโโโถ SalesDashboard โโโโโโโโโโโถ [Revenue by region]
โ โ Projection
โ โ
โ โโโโโโโโโโโโโโโถ CustomerHistory โโโโโโโโโโถ [Orders by customer]
Projection
Multiple Views, Same Events
Order Details Projection
Optimized for: Lookup by order ID
{
orderId: "ORD-123",
customerName: "John",
items: [...],
status: "shipped",
tracking: "1Z999"
}
Sales Dashboard Projection
Optimized for: Aggregations by region/time
{
region: "US-West",
month: "2024-01",
orderCount: 1547,
revenue: 234567.89
}
Customer History Projection
Optimized for: Orders by customer
{
customerId: "CUST-456",
totalOrders: 23,
lifetimeValue: 4567.89,
recentOrders: [...]
}
Building a Projection
// The projection handler processes events and updates the read model
class OrderDetailsProjection {
private readDb: ReadDatabase;
// Handle each event type
async handle(event: OrderEvent) {
switch (event.type) {
case 'OrderPlaced':
await this.readDb.insert('order_details', {
orderId: event.orderId,
customerId: event.customerId,
customerName: event.customerName, // Denormalized!
items: event.items,
totalAmount: event.totalAmount,
status: 'pending',
placedAt: event.timestamp
});
break;
case 'PaymentReceived':
await this.readDb.update('order_details',
{ orderId: event.orderId },
{ status: 'paid', paidAt: event.timestamp }
);
break;
case 'OrderShipped':
await this.readDb.update('order_details',
{ orderId: event.orderId },
{
status: 'shipped',
trackingNumber: event.trackingNumber,
carrier: event.carrier,
shippedAt: event.timestamp
}
);
break;
case 'OrderDelivered':
await this.readDb.update('order_details',
{ orderId: event.orderId },
{ status: 'delivered', deliveredAt: event.timestamp }
);
break;
}
}
}
Projection Lifecycle
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ PROJECTION LIFECYCLE โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ
โ 1. SUBSCRIBE 2. PROCESS 3. STORE โ
โ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโ โโโโโโโโโโโโโ โ
โ โ Event Stream โโโโโถโ Projection โโโโโโโถโ Read Model โ โ
โ โ โ โ Handler โ โ Database โ โ
โ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโ โ
โ โ
โ 4. QUERY (Fast!) โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ API Request โโโโโถโ Read Model โโโโโถโ Response โ โ
โ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโ โโโโโโโโโโโโ โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Power of Rebuild
One of the most powerful features of projections: you can rebuild them from scratch.
Your sales dashboard was calculating revenue wrong for 3 months. In a traditional system, you'd need complex data migration scripts. With event sourcing:
- Drop the sales_dashboard read model table
- Fix the projection code
- Replay all events from the beginning
- Projection rebuilds with correct data
class ProjectionRebuilder {
async rebuild(projection: Projection) {
// 1. Clear existing read model
await projection.clear();
// 2. Get all events from the beginning
const events = await this.eventStore.getAllEvents();
// 3. Process each event through the projection
for (const event of events) {
await projection.handle(event);
}
console.log(`Rebuilt projection with ${events.length} events`);
}
}
Handling Eventual Consistency
Projections are eventually consistent - there's a delay between an event being written and the read model being updated.
THE PROBLEM
- User places order (command)
- OrderPlaced event saved
- User redirected to "My Orders"
- Projection hasn't processed yet!
- User doesn't see their order
SOLUTIONS
- Return new data in command response
- Optimistic UI updates
- Polling with backoff
- WebSocket notifications
- Read-your-writes consistency
Real-World: Event Store Subscriptions
// Using EventStoreDB's subscription API
const subscription = client.subscribeToAll({
fromPosition: lastProcessedPosition
});
for await (const event of subscription) {
// Route to appropriate projection(s)
await orderDetailsProjection.handle(event);
await salesDashboardProjection.handle(event);
await customerHistoryProjection.handle(event);
// Track position for restart recovery
await saveCheckpoint(event.position);
}
Projection Patterns
| Pattern | Description | Use Case |
|---|---|---|
| Single Projection | One projection per aggregate type | Simple CRUD-like reads |
| Cross-Aggregate | Combines events from multiple aggregates | Dashboard, reporting |
| Search Index | Projects to Elasticsearch/Algolia | Full-text search |
| Notification | Triggers emails, webhooks, alerts | External integrations |
Real-World: Netflix Billing
From the same billing events, Netflix builds multiple projections:
- Account Projection: Current subscription status for the app
- Invoice Projection: Detailed billing history for customer service
- Revenue Projection: Financial reporting for accounting
- Analytics Projection: Churn prediction, cohort analysis
Key Takeaways
- Projections build read models - Transform events into query-optimized views
- Multiple views, same events - Each projection serves different query needs
- Rebuild anytime - Fix bugs by replaying events through corrected code
- Eventually consistent - Design your UI to handle the delay gracefully
Next up: Event Store - How to persist and replay your events reliably.