Projections
A projection transforms an event stream into a query-optimized representation. Think of it as a materialized view that stays up to date by listening to events. Projections are the query side (Q) of CQRS.
What Projections Are
Projections are read-only consumers of the event stream. They:
- Listen to events and build state by applying them
- Are optimized for querying — denormalized, pre-computed, purpose-specific
- Can be rebuilt from scratch by replaying the entire event stream
- Are eventually consistent with the write model
Declaring a Projection
Use the projection keyword to declare a projection class:
use Sourcing;
projection CustomerDashboard {
has Int $.customer-id is projection-id;
has Str $.name;
has Int $.order-count = 0;
has Rat $.total-spent = 0;
multi method apply(CustomerNameChanged $e) {
$!name = $e.name;
}
multi method apply(OrderPlaced $e) {
$!order-count++;
$!total-spent += $e.total;
}
}
Key Characteristics
| Property | Description |
|---|---|
| Read-only | Projections consume events but never emit them. They observe; they don't command. |
| Rebuildable | Given the full event stream, any projection can be reconstructed from scratch. |
| Eventually consistent | There is a delay between an event being emitted and a projection reflecting it. |
| Purpose-specific | Each projection serves a single query purpose. Don't build a "god projection." |
| Storage-agnostic | Each projection chooses its own storage mechanism based on query patterns. |
| Resilient | Malformed events are logged and skipped — a single bad event won't crash the system. |
Projection IDs
Every projection needs at least one is projection-id attribute. This identifies which instance of the projection an event applies to:
projection OrderView {
has Int $.order-id is projection-id; # This event belongs to order #42
has Str $.status = 'pending';
has Rat $.total = 0;
method apply(OrderPlaced $e) {
$!status = 'placed';
$!total = $e.total;
}
}
When an event arrives, the projection system matches the event's ID field to the projection's is projection-id attribute to find the right instance.
Custom ID Mapping
By default, the projection ID attribute name must match the event field name. Use is projection-id<> to map to a different field:
projection OrderView {
has Int $.order-id is projection-id;
# Event has 'oid' field, map it to our 'order-id'
method apply(OrderPlaced $e) is projection-id< oid > {
$!status = 'placed';
}
}
Best Practices
- Don't enforce constraints. Constraints belong in aggregations. A projection showing unexpected data is a reporting concern.
- One projection per query purpose. If two consumers need different data shapes, give them separate projections.
- Name projections after their purpose.
CustomerDashboardView, notCustomerEventProjection. - Make apply methods idempotent. Applying the same event twice should produce the same state.
Don't build a single "god projection" that serves every consumer. Each projection should serve one specific query purpose. This keeps them simple, focused, and independently optimizable.
Using Projections
# Get a projection instance (creates fresh, replays all events)
my $dashboard = sourcing CustomerDashboard, :customer-id(42);
say "Customer: $dashboard.name";
say "Orders: $dashboard.order-count";
say "Total spent: $dashboard.total-spent";
The sourcing() function creates a fresh instance and replays all events from the store. The projection's current state reflects all events that have been emitted.
Next Steps
- Learn about Aggregations — the write side that emits events
- See the Building Projections Guide for detailed examples
- Read the API Reference for Sourcing::Projection