Declarative Projections
Declarative projections let you define read models with a fluent, code-first projection builder. They are ideal when you need explicit mapping, joins, or hierarchical projections that go beyond simple attribute mapping.
Overview
Section titled “Overview”Declarative projections implement IProjectionFor<TReadModel> and use an IProjectionBuilderFor<TReadModel> to define how events map to read model properties. The builder gives you control over mapping, relationships, and event context while keeping the projection definition separate from the read model type.
Basic Example
Section titled “Basic Example”using Cratis.Chronicle.Events;using Cratis.Chronicle.Projections;
[EventType]public record DecIndexUserRegistered(string Name, string Email, DateTimeOffset RegisteredAt);
public record DecIndexUserProfile(string Name, string Email, DateTimeOffset RegisteredAt);
public class DecIndexUserProfileProjection : IProjectionFor<DecIndexUserProfile>{ public void Define(IProjectionBuilderFor<DecIndexUserProfile> builder) => builder .From<DecIndexUserRegistered>();}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.projections.IProjectionBuilderForimport io.cratis.chronicle.projections.IProjectionForimport java.time.OffsetDateTime
@EventTypedata class DecIndexUserRegistered(val name: String, val email: String, val registeredAt: OffsetDateTime)
data class DecIndexUserProfile( val name: String = "", val email: String = "", val registeredAt: OffsetDateTime? = null)
class DecIndexUserProfileProjection : IProjectionFor<DecIndexUserProfile> { override fun define(builder: IProjectionBuilderFor<DecIndexUserProfile>) { builder.from(DecIndexUserRegistered::class) }}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.projections.IProjectionBuilderFor;import io.cratis.chronicle.projections.IProjectionFor;
@EventTyperecord DecIndexUserRegistered(String name, String email, String registeredAt) {}
class DecIndexUserProfile { public String name = ""; public String email = ""; public String registeredAt = null;}
class DecIndexUserProfileProjection implements IProjectionFor<DecIndexUserProfile> { @Override public void define(IProjectionBuilderFor<DecIndexUserProfile> builder) { builder.from(DecIndexUserRegistered.class); }}defmodule MyApp.Events.DecIndexUserRegistered do use Chronicle.Events.EventType, id: "dec-index-user-registered"
defstruct [:name, :email, :registered_at]end
defmodule MyApp.ReadModels.DecIndexUserProfile do use Chronicle.ReadModels.ReadModel
defstruct [:name, :email, :registered_at]end
defmodule MyApp.Projections.DecIndexUserProfileProjection do use Chronicle.Projections.Projection, model: MyApp.ReadModels.DecIndexUserProfile
alias MyApp.Events.DecIndexUserRegistered
from DecIndexUserRegisteredendimport { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle';
@eventType()class DecIndexUserRegistered { name = ''; email = ''; registeredAt = new Date();}
class DecIndexUserProfile { name = ''; email = ''; registeredAt = new Date();}
@projection()class DecIndexUserProfileProjection implements IProjectionFor<DecIndexUserProfile> { define(builder: IProjectionBuilderFor<DecIndexUserProfile>): void { builder.from(DecIndexUserRegistered); }}Auto-mapping is enabled by default at the top level, so matching properties are mapped automatically. When you need explicit mappings, you can use .Set(), .Add(), .Subtract(), and other builder operations.
Discovery
Section titled “Discovery”Projection types are discovered by implementing IProjectionFor<TReadModel>. The events must be
registered in the owning client’s event-type registry, normally through [EventType]; .NET also
supports explicit event nominations.
In .NET, a projection class must target only one read model. Implementing two closed IProjectionFor<TReadModel> interfaces, including through inheritance, throws TypeImplementsMultipleProjectionInterfaces during default artifact discovery. Split the class into one projection per read model. CHR0057 reports this before runtime.
Key Features
Section titled “Key Features”- Auto mapping by default with the option to turn it off or override per event
- Explicit property mappings for custom transformations
- Hierarchies with child collections and nested projections
- Joins across streams for richer read models
- FromEvery for applying mappings to all events
- Event context access for timestamps, sequence numbers, and IDs
- Event sequence selection for sourcing from non-default sequences
- Passive and not rewindable projections for specialized observation behavior
- Variants for mutually exclusive read model shapes of the same logical entity
When to Use
Section titled “When to Use”Start with model-bound attributes; use fluent mappings when keys, joins, hierarchy, or keeping the model attribute-free makes a separate definition clearer. Use reducer code when the logic reads better as state transitions, with a self-reducing model as the default for new reducers. Follow Choose how to build a read model for scenarios and limits.
Define emits a kernel-executed definition. Dependencies can help build it, but the builder lambdas
are not arbitrary per-event handlers with dependency injection.
Projection recipes
Section titled “Projection recipes”| Recipe | Description |
|---|---|
| Simple projection | Declare events and use the default AutoMap behavior |
| AutoMap | Automatic property mapping at different levels |
| Passive | In-memory projections for on-demand lookups |
| Set properties | Explicit property mapping and transformations |
| Children | Hierarchical data models with child collections |
| Joins | Cross-stream projections using joins |
| Functions | Arithmetic and other functions |
| Composite keys | Multi-property key identification |
| Event context | Using event metadata in projections |
| FromEvery | Setting properties for all events in a projection |
| FromAll | Subscribe to all event types without filtering |
| Initial values | Default values for read model properties |
| RemoveWithJoin | Cross-stream child removal |
| FromEventSequence | Sourcing events from specific event sequences |
| NotRewindable | Forward-only projections that cannot be replayed |
| Variants | Mutually exclusive read model shapes for the same logical entity |
Reading Your Declarative Projections
Section titled “Reading Your Declarative Projections”Once you’ve defined a declarative projection, you can retrieve and observe the resulting read models using the IReadModels API:
- Getting a Single Instance - Retrieve a specific instance by key
- Getting a Collection of Instances - Retrieve all instances for reporting and analysis
- Getting Snapshots - Retrieve historical state snapshots grouped by correlation ID
- Watching Read Models - Observe real-time changes as events are applied
Key concepts
Section titled “Key concepts”Auto-mapping vs explicit mapping
Section titled “Auto-mapping vs explicit mapping”- Auto-mapping: Automatically maps properties with matching names between events and read models
- Enabled by default in fluent projections; call
.AutoMap()only to re-enable it in a scope that disabled it - Use
[FromEvent<TEvent>]in model-bound projections for the same functionality
- Enabled by default in fluent projections; call
- Explicit mapping: Gives you full control over property transformations and mappings
- Use
.Set(),.Add(), etc. in fluent projections - Use
[SetFrom<TEvent>],[AddFrom<TEvent>], etc. in model-bound projections
- Use
Event handling
Section titled “Event handling”- Projections can handle multiple event types
- Each event type can have its own property mappings
- Properties are updated incrementally as events are processed
Keys and identification
Section titled “Keys and identification”- EventSourceId is used as the default key for both read models and parent identification in child collections
- Child identifiers: Use
.IdentifiedBy()to specify how child items are uniquely identified within collections - Parent key resolution:
- By default, the EventSourceId is used to identify the parent when adding children
- Use
.UsingParentKey(e => e.Property)when the parent identifier is in the event content - Use
.UsingParentKeyFromContext(ctx => ctx.EventSourceId)to explicitly document default behavior
- Child key specification: Use
.UsingKey(e => e.Property)to extract the child identifier from event content - Joins: Use keys to link data from different event streams using
.On(m => m.Property)
Container name
Section titled “Container name”By default the read model is stored in a container (collection, table) named by the client’s naming policy. In C#, call ContainerName("name") on the projection builder to choose a different one. This sets only the container name; the read model keeps its identifier, so the projection still resolves its read model. Every projection of the same read model must use the same container name, otherwise the client reports a conflict. Changing the container name of a read model that already has data points it at a new, empty container, so replay the projection afterwards.
Performance
Section titled “Performance”- Projections are automatically maintained as events are appended
- The system handles indexing and query optimization
- Consider join complexity and update frequency when designing projections