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 withGetAsyncandQueryAsyncInMemoryOrderSummaryProjection—ConcurrentDictionary-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;
}
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:
- Determine the
GetAsyncparameter type (e.g.,OrderId) - Map between the stream identity and the projection's primary key
- 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);