Building Projections

A detailed guide to creating effective projections for your event-sourced application.

Basic Projection

Every projection needs at least one is projection-id attribute and apply methods:

projection UserView {
    has Int $.user-id is projection-id;
    has Str $.name;
    has Str $.email;

    method apply(UserCreated $e) {
        $!name = $e.name;
        $!email = $e.email;
    }
    method apply(UserNameChanged $e) {
        $!name = $e.new-name;
    }
}

Custom ID Mapping

When the event field name doesn't match your projection ID attribute:

projection OrderView {
    has Int $.order-id is projection-id;

    # Event has 'oid' field, map it to our 'order-id'
    method apply(OrderCreated $e) is projection-id< oid > {
        $!status = 'created';
    }
}

Aggregating Data

Projections can compute derived values:

projection RevenueByMonth {
    has Str $.month is projection-id;  # e.g., "2024-03"
    has Rat $.total = 0;
    has Int $.order-count = 0;
    has Rat $.average-order = 0;

    method apply(OrderPlaced $e) {
        $!total += $e.total;
        $!order-count++;
        $!average-order = $!total / $!order-count;
    }
}

Tracking Collections

Projections can maintain lists of related data:

projection CustomerOrderHistory {
    has Int $.customer-id is projection-id;
    has Array @.orders;

    method apply(OrderPlaced $e) {
        @!orders.push: {
            order-id => $e.order-id,
            total    => $e.total,
            date     => $e.date,
        };
    }
}

Best Practices

1. One Projection Per Query Purpose

Don't build a "god projection." Each projection should serve one specific query:

# Good: focused projections
projection UserDashboard { ... }
projection UserAdminView { ... }
projection UserAuditLog  { ... }

# Bad: one projection trying to do everything
projection UserEverything {
    has $.dashboard-data;
    has $.admin-data;
    has $.audit-log;
    # ...
}

2. Make Apply Methods Idempotent

Applying the same event twice should produce the same state:

# Good: idempotent
method apply(UserNameChanged $e) {
    $!name = $e.new-name;  # Same result every time
}

# Bad: not idempotent
method apply(UserNameChanged $e) {
    $!name ~= $e.new-name;  # Duplicates on replay!
}

3. Name Projections After Their Purpose

# Good
projection CustomerDashboardView
projection MonthlyRevenueReport
projection FraudDetectionAlert

# Bad
projection CustomerEventProjection
projection OrderProjection

4. Don't Enforce Constraints

Constraints belong in aggregations. If a projection shows unexpected data, it's a reporting concern:

# Good: just reflect the events
method apply(InventoryAdjusted $e) {
    $!quantity += $e.delta;
}

# Bad: trying to enforce rules
method apply(InventoryAdjusted $e) {
    die "Quantity can't be negative" if $!quantity + $e.delta < 0;
    $!quantity += $e.delta;
}

Next Steps