Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Projections

Projections are read models built by replaying events. They are query-oriented and eventually consistent.

For most projections, keep to this shape:

  1. #[derive(Projection)]
  2. #[projection(events(...))]
  3. impl ApplyProjection<E> for each event
use sourcery::ApplyProjection;

#[derive(Debug, Default, sourcery::Projection)]
#[projection(events(FundsDeposited, FundsWithdrawn))]
pub struct AccountSummary {
    pub accounts: HashMap<String, i64>,
}

impl ApplyProjection<FundsDeposited> for AccountSummary {
    fn apply_projection(&mut self, id: &Self::Id, event: &FundsDeposited, _: &Self::Metadata) {
        *self.accounts.entry(id.clone()).or_default() += event.amount;
    }
}

impl ApplyProjection<FundsWithdrawn> for AccountSummary {
    fn apply_projection(&mut self, id: &Self::Id, event: &FundsWithdrawn, _: &Self::Metadata) {
        *self.accounts.entry(id.clone()).or_default() -= event.amount;
    }
}

#[projection(events(...))] auto-generates ProjectionFilters for the common case.

Loading Projections

// Singleton projection (InstanceId = ())
let summary = repository
    .load_projection::<AccountSummary>(&())
    .await?;

// Instance projection (InstanceId = String)
let report = repository
    .load_projection::<CustomerReport>(&customer_id)
    .await?;

When to Implement ProjectionFilters Manually

Use manual filters when you need:

  • Dynamic filtering
  • Scoped filtering (event_for / events_for)
  • Non-default initialisation
  • Full control over Id, InstanceId, or Metadata

Example: Scoped Instance Filter

impl ProjectionFilters for AccountComparison {
    type Id = String;
    type InstanceId = (String, String);
    type Metadata = ();

    fn init(ids: &Self::InstanceId) -> Self {
        Self {
            left_id: ids.0.clone(),
            right_id: ids.1.clone(),
            ..Self::default()
        }
    }

    fn filters<S>(ids: &Self::InstanceId) -> Filters<S, Self>
    where
        S: EventStore<Id = Self::Id, Metadata = Self::Metadata>,
    {
        let (left, right) = ids;
        Filters::new()
            .events_for::<Account>(left)
            .events_for::<Account>(right)
    }
}

Filters Cheat Sheet

Filters::new()
    .event::<FundsDeposited>()                 // Global: all aggregates
    .event_for::<Account, FundsWithdrawn>(&id) // One event type, one aggregate instance
    .events::<AccountEvent>()                  // All kinds in an event enum
    .events_for::<Account>(&id)                // All event kinds for one aggregate instance
MethodScope
.event::<E>()All events of type E across all aggregates
.event_for::<A, E>(&id)Events of type E from one aggregate instance
.events::<Enum>()All event kinds in a ProjectionEvent enum
.events_for::<A>(&id)All event kinds for one aggregate instance

Metadata in Projections

Use metadata = ... in the derive attribute for the common case:

#[derive(Debug, Default, sourcery::Projection)]
#[projection(metadata = EventMetadata, events(FundsDeposited))]
struct AuditLog {
    entries: Vec<AuditEntry>,
}

impl ApplyProjection<FundsDeposited> for AuditLog {
    fn apply_projection(
        &mut self,
        id: &Self::Id,
        event: &FundsDeposited,
        meta: &Self::Metadata,
    ) {
        self.entries.push(AuditEntry {
            timestamp: meta.timestamp,
            user: meta.user_id.clone(),
            action: format!("Deposited {} to {}", event.amount, id),
        });
    }
}

Singleton vs Instance Projections

  • Singleton (InstanceId = ()): one global projection.
  • Instance (InstanceId = String or custom type): one projection per instance ID.

apply_projection receives the aggregate ID, not the instance ID. If handlers need instance context, store it during init.

Trait Reference

For full signatures, see the API docs:

  • Projection
  • ApplyProjection<E>
  • ProjectionFilters

You can also inspect the trait definitions directly:

pub trait Projection {
    /// Stable identifier for this projection type.
    const KIND: &'static str;
}
pub trait ApplyProjection<E>: ProjectionFilters {
    fn apply_projection(&mut self, aggregate_id: &Self::Id, event: &E, metadata: &Self::Metadata);
}
pub trait ProjectionFilters: Sized {
    /// Aggregate identifier type this subscriber is compatible with.
    type Id;
    /// Instance identifier for this subscriber.
    ///
    /// For singleton subscribers use `()`.
    type InstanceId;
    /// Metadata type expected by this subscriber's handlers.
    type Metadata;

    /// Construct a fresh instance from the instance identifier.
    ///
    /// For singleton projections (`InstanceId = ()`), this typically
    /// delegates to `Self::default()`. For instance projections, this
    /// captures the instance identifier at construction time.
    fn init(instance_id: &Self::InstanceId) -> Self;

    /// Build the filter set and handler map for this subscriber.
    fn filters<S>(instance_id: &Self::InstanceId) -> Filters<S, Self>
    where
        S: EventStore<Id = Self::Id, Metadata = Self::Metadata>;
}

Snapshotting Projections

Projections support snapshots for faster loading. See Snapshots — Projection Snapshots.

Projections vs Aggregates

AspectAggregateProjection
PurposeEnforce invariantsServe queries
State sourceOwn events onlyAny events
Receives IDsNo (in envelope)Yes (as parameter)
Receives metadataNoYes
ConsistencyStrongEventual
MutabilityVia commands onlyRebuilt on demand

Next

Stores — Event persistence and serialisation