Skip to content

Projections

Projections transform event streams into queryable read models. The [Projection] attribute marks a partial class as a single-stream projection, and the generator produces a read-only query interface, in-memory implementation, and effect methods.

Overview

using Deepstaging.EventStore;

[Projection]
public partial class OrderSummary
{
    public OrderId Id { get; init; }
    public string CustomerName { get; set; } = "";
    public int ItemCount { get; set; }

    public static OrderSummary Create(OrderCreated e) =>
        new() { Id = e.OrderId, CustomerName = e.CustomerName };

    public void Apply(OrderItemAdded e) =>
        ItemCount += e.Quantity;
}

This generates:

  • IOrderSummaryProjection — read-only query interface with GetAsync and QueryAsync
  • InMemoryOrderSummaryProjectionConcurrentDictionary-backed test implementation
  • DI registration via the event store bootstrap
  • Effect methods on the event store container (when using effects)

[Projection]

Property Type Default Description
Lifecycle ProjectionLifecycle Inline When the projection is updated

Lifecycle

Value Behavior Consistency
Inline Updated synchronously during CommitAsync() Strong — projection is immediately consistent after commit
Async Updated by a background worker after commit Eventual — projection may lag behind the event stream
// Strong consistency (default)
[Projection]
public partial class OrderSummary { ... }

// Eventual consistency
[Projection(Lifecycle = ProjectionLifecycle.Async)]
public partial class OrderSummary { ... }

When to use Async

Use Async projections for read models that don't need to be immediately consistent — dashboards, reports, search indexes. Use Inline for projections read immediately after a command (e.g., returning updated state to the caller).

Create/Apply Convention

Projections use the same convention as aggregates:

Method Signature Purpose
Create static T Create(TEvent) Factory — builds the projection from the first event
Apply void Apply(TEvent) or T Apply(TEvent) Updates projection state from subsequent events

Projections support both mutable (void Apply) and immutable (T Apply) styles:

[Projection]
public partial class OrderSummary
{
    public OrderId Id { get; init; }
    public string CustomerName { get; set; } = "";
    public int ItemCount { get; set; }

    public static OrderSummary Create(OrderCreated e) =>
        new() { Id = e.OrderId, CustomerName = e.CustomerName };

    public void Apply(OrderItemAdded e) =>
        ItemCount += e.Quantity;
}
[Projection]
public partial record OrderSummary(OrderId Id, string CustomerName, int ItemCount)
{
    public static OrderSummary Create(OrderCreated e) =>
        new(e.OrderId, e.CustomerName, 0);

    public OrderSummary Apply(OrderItemAdded e) =>
        this with { ItemCount = ItemCount + e.Quantity };
}

Generated Interface

public interface IOrderSummaryProjection
{
    Task<OrderSummary?> GetAsync(OrderId id, CancellationToken ct = default);
    Task<QueryResult<OrderSummary>> QueryAsync(int page, int pageSize, CancellationToken ct = default);
}

The identity parameter type matches the [StreamId]-typed property on the projection class.

QueryResult<T>

Offset-based pagination result:

Property Type Description
Data IReadOnlyList<T> Items for the current page
TotalCount int Total items across all pages
Page int Current page number (1-based)
PageSize int Items per page
TotalPages int Computed: ⌈TotalCount / PageSize⌉
HasNextPage bool Computed: Page < TotalPages
HasPreviousPage bool Computed: Page > 1

DI Registration

Projection stores are registered alongside aggregate stores in the bootstrap:

public static IServiceCollection AddAppEventStore(this IServiceCollection services)
{
    services.TryAddSingleton<IOrderEventStore, InMemoryOrderEventStore>();
    services.TryAddSingleton<IOrderSummaryProjection, InMemoryOrderSummaryProjection>();
    return services;
}

Direct Injection

Inject IOrderSummaryProjection directly:

public class OrderQueryService(IOrderSummaryProjection projections)
{
    public Task<OrderSummary?> GetSummaryAsync(OrderId id) =>
        projections.GetAsync(id);

    public Task<QueryResult<OrderSummary>> ListAsync(int page, int pageSize) =>
        projections.QueryAsync(page, pageSize);
}

Using with Effects

Effect methods are generated as nested classes on the event store container. The projection plural name determines the nested class name:

public static partial class AppEventStore
{
    public static partial class OrderSummarys
    {
        public static Eff<RT, Option<OrderSummary>> Get<RT>(OrderId id)
            where RT : IHasAppEventStore => ...

        public static Eff<RT, QueryResult<OrderSummary>> Query<RT>(int page, int pageSize)
            where RT : IHasAppEventStore => ...
    }
}

Usage in a query handler:

public static class OrderQueries
{
    [QueryHandler]
    public static Eff<AppRuntime, QueryResult<OrderSummary>> Handle(GetOrders query) =>
        AppEventStore.OrderSummarys.Query<AppRuntime>(query.Page, query.PageSize);
}

Identity Requirements

A projection must have a property whose type is decorated with [StreamId]:

[TypedId]
[StreamId]
public readonly partial struct OrderId;

[Projection]
public partial class OrderSummary
{
    public OrderId Id { get; init; }  // ← discovered as the stream identity
    // ...
}

The generator uses this property to:

  1. Determine the GetAsync parameter type (e.g., OrderId)
  2. Map between the stream identity and the projection's primary key
  3. Configure infrastructure backends (e.g., Marten's snapshot identity)

Marten Backend

When using the Deepstaging.EventStore.Marten package, the Marten generator produces a Marten{T}Projection implementation backed by IQuerySession:

// Generated by Deepstaging.EventStore.Marten
internal sealed class MartenOrderSummaryProjection : IOrderSummaryProjection
{
    private readonly IQuerySession _session;
    // GetAsync → _session.LoadAsync<OrderSummary>(id.Value)
    // QueryAsync → _session.Query<OrderSummary>() with Skip/Take
}

Marten's snapshot projections handle the Create/Apply logic natively. The Lifecycle property maps to Marten's SnapshotLifecycle:

Deepstaging Marten
ProjectionLifecycle.Inline SnapshotLifecycle.Inline
ProjectionLifecycle.Async SnapshotLifecycle.Async

See Marten Backend for full details on the Marten integration.

Complete Example

using Deepstaging.EventStore;
using Deepstaging.Ids;

// Identity
[TypedId] [StreamId] public readonly partial struct OrderId;

// Events
public sealed record OrderCreated(OrderId OrderId, string CustomerName) : IAggregateEvent;
public sealed record OrderItemAdded(string Sku, int Quantity) : IAggregateEvent;

// Aggregate
[EventSourcedAggregate]
public partial record Order(OrderId Id, string CustomerName, int ItemCount)
{
    public static Order Create(OrderCreated e) => new(e.OrderId, e.CustomerName, 0);
    public Order Apply(OrderItemAdded e) => this with { ItemCount = ItemCount + e.Quantity };
}

// Projection — separate read model optimized for queries
[Projection]
public partial class OrderSummary
{
    public OrderId Id { get; init; }
    public string CustomerName { get; set; } = "";
    public int ItemCount { get; set; }

    public static OrderSummary Create(OrderCreated e) =>
        new() { Id = e.OrderId, CustomerName = e.CustomerName };

    public void Apply(OrderItemAdded e) =>
        ItemCount += e.Quantity;
}

// Event store
[EventStore]
public static partial class AppEventStore;

// Query the projection
var summary = await projectionStore.GetAsync(orderId);
var page = await projectionStore.QueryAsync(page: 1, pageSize: 20);