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:

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

PropertyDescription
Read-onlyProjections consume events but never emit them. They observe; they don't command.
RebuildableGiven the full event stream, any projection can be reconstructed from scratch.
Eventually consistentThere is a delay between an event being emitted and a projection reflecting it.
Purpose-specificEach projection serves a single query purpose. Don't build a "god projection."
Storage-agnosticEach projection chooses its own storage mechanism based on query patterns.
ResilientMalformed 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

Common Mistake

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