Model-Bound Projections
Model-bound projections let the read model describe how it is built. Instead of creating a separate IProjectionFor<T> class, you put the projection metadata directly on the read model type and Chronicle builds the projection definition from those attributes.
Overview
Section titled “Overview”Start with convention-based mapping and add explicit attributes only where the event and read model use different names. That keeps the common case small while still making the exceptions visible.
Basic Example
Section titled “Basic Example”using Cratis.Chronicle.Events;using Cratis.Chronicle.Keys;using Cratis.Chronicle.Projections.ModelBound;
[EventType]public record MbIndexAccountOpened(string Name, decimal InitialBalance);
[FromEvent<MbIndexAccountOpened>]public record MbIndexAccountInfo( [Key] Guid Id,
string Name,
[SetFrom<MbIndexAccountOpened>(nameof(MbIndexAccountOpened.InitialBalance))] decimal Balance);import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.projections.FromEventimport io.cratis.chronicle.projections.SetFromimport io.cratis.chronicle.readModels.ReadModel
@EventTypedata class IndexAccountOpened( val name: String, val initialBalance: Double)
@ReadModel@FromEvent(IndexAccountOpened::class)data class IndexAccountInfo( val name: String = "",
@SetFrom("initialBalance") val balance: Double = 0.0)import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.projections.FromEvent;import io.cratis.chronicle.projections.SetFrom;import io.cratis.chronicle.readModels.ReadModel;
@EventTyperecord IndexAccountOpened(String name, double initialBalance) {}
@ReadModel@FromEvent(eventType = IndexAccountOpened.class)class IndexAccountInfo { public String name = "";
@SetFrom(propertyPath = "initialBalance") public double balance = 0;}defmodule MyApp.Events.IndexAccountOpened do use Chronicle.Events.EventType, id: "index-account-opened-v1"
defstruct [:name, :initial_balance]end
defmodule MyApp.ReadModels.IndexAccountInfo do use Chronicle.ReadModels.ReadModel
alias MyApp.Events.IndexAccountOpened
defstruct [:id, :name, balance: 0]
from IndexAccountOpened, set: [id: :event_source_id, name: :name, balance: :initial_balance]endimport { eventType, fromEvent, Guid, setFrom } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()export class MbIndexAccountOpened { name = ''; initialBalance = 0;}
@fromEvent(MbIndexAccountOpened)export class MbIndexAccountInfo { id: Guid = Guid.empty; @field(String) name = '';
@setFrom(MbIndexAccountOpened, 'initialBalance') balance = 0;}In this example:
[FromEvent<AccountOpened>]creates or updates anAccountInfoinstance when anAccountOpenedevent is processed[Key]marks the read model identifierNamemaps by convention because both the event and the read model use the same property nameBalanceuses[SetFrom<TEvent>]because the event property is calledInitialBalance
Discovery
Section titled “Discovery”Types are automatically discovered by the presence of:
- Class/record-level projection annotations such as
FromEventorNotRewindable, or event-sequence selection - Member mapping attributes such as
SetFrom,AddFrom, orJoin
No explicit marker is required on the type. [Passive] alone is not a discovery signal: it configures
how an already-defined model is read, and can also configure a reducer model.
Key Features
Section titled “Key Features”Model-bound projections cover the common projection operations below. Specialized builder features, such as fluent dynamic dictionary counters, do not all have attribute equivalents.
- Property Mapping: SetFrom, AddFrom, SubtractFrom, SetFromContext
- FromEvery: Set properties from all events
- Counters: Increment, Decrement, Count
- Relationships: Join, Children (with unlimited nesting depth)
- Removal: RemovedWith, RemovedWithJoin
- Convention-based: FromEvent for automatic property matching
- Configuration: EventSequence, NotRewindable, Passive
- Variants: Mutually exclusive read model shapes for the same logical entity
- Fully Recursive: All attributes work at any nesting level - children, grandchildren, and beyond
See the following pages for detailed information on each feature:
- Basic Mapping - SetFrom, AddFrom, SubtractFrom, SetFromContext
- Set Constant Value - Set a property to a fixed value with SetValue
- Clearing Values - Take a value away with ClearWith or a null SetValue
- Convention-Based - Automatic property mapping with FromEvent (equivalent to AutoMap)
- FromEvery - Update properties from all events
- FromAll - Subscribe to all event types without filtering
- Counters - Increment, Decrement, Count
- Children - Managing child collections
- Removal - Removing read models and children with RemovedWith, RemovedWithJoin
- Joins - Joining with other events
- Event Sequence Source - Reading from specific event sequences
- Not Rewindable - Forward-only projections
- Passive - On-demand projections
- Variants - Mutually exclusive read model shapes for the same logical entity
When to Use
Section titled “When to Use”Start with model-bound attributes, use fluent mappings when the definition needs structure, and use reducer code when state transitions are clearer; a self-reducing model is the default for new reducers. Choose how to build a read model works through the trade-offs.
Comparison with Fluent Projections
Section titled “Comparison with Fluent Projections”Explicit Property Mapping
Section titled “Explicit Property Mapping”Fluent Projection:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Projections;
[EventType]public record MbIndexExplicitAccountOpened(string Name, decimal InitialBalance);
public class MbIndexExplicitAccountProjection : IProjectionFor<MbIndexExplicitAccountInfo>{ public void Define(IProjectionBuilderFor<MbIndexExplicitAccountInfo> builder) => builder .From<MbIndexExplicitAccountOpened>(_ => _ .Set(m => m.Balance).To(e => e.InitialBalance));}
public class MbIndexExplicitAccountInfo{ public string Name { get; set; } = string.Empty; public decimal Balance { get; set; }}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.projections.IProjectionBuilderForimport io.cratis.chronicle.projections.IProjectionFor
@EventTypedata class IndexExplicitAccountOpened( val name: String, val initialBalance: Double)
data class IndexExplicitAccountInfo( val name: String = "", val balance: Double = 0.0)
class IndexExplicitAccountProjection : IProjectionFor<IndexExplicitAccountInfo> { override fun define(builder: IProjectionBuilderFor<IndexExplicitAccountInfo>) { builder.from(IndexExplicitAccountOpened::class) { it.set(IndexExplicitAccountInfo::name).toProperty("name") it.set(IndexExplicitAccountInfo::balance).toProperty("initialBalance") } }}import io.cratis.chronicle.projections.IProjectionBuilderFor;import io.cratis.chronicle.projections.IProjectionFor;import io.cratis.chronicle.readModels.ReadModel;
@ReadModelrecord IndexExplicitAccountInfo(String name, double balance) {}
class IndexExplicitAccountProjection implements IProjectionFor<IndexExplicitAccountInfo> { @Override public void define(IProjectionBuilderFor<IndexExplicitAccountInfo> builder) { builder.from(IndexExplicitAccountOpened.class, from -> { from.set("name").toProperty("name"); from.set("balance").toProperty("initialBalance"); }); }}defmodule MyApp.Events.IndexExplicitAccountOpened do use Chronicle.Events.EventType, id: "index-explicit-account-opened-v1"
defstruct [:name, :initial_balance]end
defmodule MyApp.ReadModels.IndexExplicitAccountInfo do use Chronicle.ReadModels.ReadModel
defstruct [:name, balance: 0]end
defmodule MyApp.Projections.IndexExplicitAccountProjection do use Chronicle.Projections.Projection, model: MyApp.ReadModels.IndexExplicitAccountInfo
alias MyApp.Events.IndexExplicitAccountOpened
from IndexExplicitAccountOpened, set: [name: :name, balance: :initial_balance]endimport { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle';
@eventType()class MbIndexExplicitAccountOpened { name = ''; initialBalance = 0;}
@projection()class MbIndexExplicitAccountProjection implements IProjectionFor<MbIndexExplicitAccountInfo> { define(builder: IProjectionBuilderFor<MbIndexExplicitAccountInfo>): void { builder.from(MbIndexExplicitAccountOpened, _ => _ .set(m => m.name).to(e => e.name) .set(m => m.balance).to(e => e.initialBalance)); }}
class MbIndexExplicitAccountInfo { name = ''; balance = 0;}Model-Bound Projection:
using Cratis.Chronicle.Keys;using Cratis.Chronicle.Projections.ModelBound;
public record MbIndexExplicitMbAccountInfo( [Key] Guid Id, [SetFrom<MbIndexExplicitAccountOpened>(nameof(MbIndexExplicitAccountOpened.Name))] string Name, [SetFrom<MbIndexExplicitAccountOpened>(nameof(MbIndexExplicitAccountOpened.InitialBalance))] decimal Balance);import io.cratis.chronicle.projections.SetFromimport io.cratis.chronicle.projections.FromEventimport io.cratis.chronicle.readModels.ReadModel
@ReadModel@FromEvent(IndexExplicitAccountOpened::class)data class IndexExplicitMbAccountInfo( @SetFrom("name", IndexExplicitAccountOpened::class) val name: String = "",
@SetFrom("initialBalance", IndexExplicitAccountOpened::class) val balance: Double = 0.0)import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.projections.SetFrom;import io.cratis.chronicle.projections.FromEvent;import io.cratis.chronicle.readModels.ReadModel;
@EventTyperecord IndexExplicitAccountOpened(String name, double initialBalance) {}
@ReadModel@FromEvent(eventType = IndexExplicitAccountOpened.class)class IndexExplicitMbAccountInfo { @SetFrom(propertyPath = "name", eventType = IndexExplicitAccountOpened.class) public String name = "";
@SetFrom(propertyPath = "initialBalance", eventType = IndexExplicitAccountOpened.class) public double balance = 0;}defmodule MyApp.ReadModels.IndexExplicitMbAccountInfo do use Chronicle.ReadModels.ReadModel
alias MyApp.Events.IndexExplicitAccountOpened
defstruct [:name, balance: 0]
from IndexExplicitAccountOpened, set: [name: :name, balance: :initial_balance]endimport { Guid, setFrom } from '@cratis/chronicle';
class MbIndexExplicitMbAccountInfo { id: Guid = Guid.empty;
@setFrom(MbIndexExplicitAccountOpened, 'name') name = '';
@setFrom(MbIndexExplicitAccountOpened, 'initialBalance') balance = 0;}Automatic Property Mapping
Section titled “Automatic Property Mapping”Fluent Projection (AutoMap is on by default):
using Cratis.Chronicle.Events;using Cratis.Chronicle.Projections;
[EventType]public record MbIndexAutoMapAccountOpened(string Name, decimal Balance);
public class MbIndexAutoMapAccountProjection : IProjectionFor<MbIndexAutoMapAccountInfo>{ public void Define(IProjectionBuilderFor<MbIndexAutoMapAccountInfo> builder) => builder .From<MbIndexAutoMapAccountOpened>();}
public class MbIndexAutoMapAccountInfo{ public string Name { get; set; } = string.Empty; public decimal Balance { get; set; }}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.projections.IProjectionBuilderForimport io.cratis.chronicle.projections.IProjectionFor
@EventTypedata class IndexAutoMapAccountOpened( val name: String, val balance: Double)
data class IndexAutoMapAccountInfo( val name: String = "", val balance: Double = 0.0)
class IndexAutoMapAccountProjection : IProjectionFor<IndexAutoMapAccountInfo> { override fun define(builder: IProjectionBuilderFor<IndexAutoMapAccountInfo>) { // No configure block — matching properties are mapped automatically. builder.from(IndexAutoMapAccountOpened::class) }}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.projections.IProjectionBuilderFor;import io.cratis.chronicle.projections.IProjectionFor;
@EventTyperecord IndexAutoMapAccountOpened(String name, double balance) {}
class IndexAutoMapAccountInfo { public String name = ""; public double balance = 0;}
class IndexAutoMapAccountProjection implements IProjectionFor<IndexAutoMapAccountInfo> { @Override public void define(IProjectionBuilderFor<IndexAutoMapAccountInfo> builder) { // No configure block — matching properties are mapped automatically. builder.from(IndexAutoMapAccountOpened.class); }}defmodule MyApp.Events.IndexAutoMapAccountOpened do use Chronicle.Events.EventType, id: "index-automap-account-opened-v1"
defstruct [:name, :balance]end
defmodule MyApp.ReadModels.IndexAutoMapAccountInfo do use Chronicle.ReadModels.ReadModel
defstruct [:name, balance: 0]end
defmodule MyApp.Projections.IndexAutoMapAccountProjection do use Chronicle.Projections.Projection, model: MyApp.ReadModels.IndexAutoMapAccountInfo
alias MyApp.Events.IndexAutoMapAccountOpened
# No `set:` list — matching properties are mapped automatically. from IndexAutoMapAccountOpenedendimport { eventType, IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle';
@eventType()class MbIndexAutoMapAccountOpened { name = ''; balance = 0;}
@projection()class MbIndexAutoMapAccountProjection implements IProjectionFor<MbIndexAutoMapAccountInfo> { define(builder: IProjectionBuilderFor<MbIndexAutoMapAccountInfo>): void { builder.autoMap().from(MbIndexAutoMapAccountOpened); }}
class MbIndexAutoMapAccountInfo { name = ''; balance = 0;}Model-Bound Projection with FromEvent:
using Cratis.Chronicle.Keys;using Cratis.Chronicle.Projections.ModelBound;
[FromEvent<MbIndexAutoMapAccountOpened>]public record MbIndexAutoMapMbAccountInfo( [Key] Guid Id, string Name, // Automatically mapped from MbIndexAutoMapAccountOpened.Name decimal Balance); // Automatically mapped from MbIndexAutoMapAccountOpened.Balanceimport io.cratis.chronicle.projections.FromEventimport io.cratis.chronicle.readModels.ReadModel
@ReadModel@FromEvent(IndexAutoMapAccountOpened::class)data class IndexAutoMapMbAccountInfo( val name: String = "", // Automatically mapped from IndexAutoMapAccountOpened.name val balance: Double = 0.0 // Automatically mapped from IndexAutoMapAccountOpened.balance)import io.cratis.chronicle.projections.FromEvent;import io.cratis.chronicle.readModels.ReadModel;
@ReadModel@FromEvent(eventType = IndexAutoMapAccountOpened.class)class IndexAutoMapMbAccountInfo { public String name = ""; // Automatically mapped from IndexAutoMapAccountOpened.name public double balance = 0; // Automatically mapped from IndexAutoMapAccountOpened.balance}defmodule MyApp.ReadModels.IndexAutoMapMbAccountInfo do use Chronicle.ReadModels.ReadModel
alias MyApp.Events.IndexAutoMapAccountOpened
# `name` and `balance` are mapped automatically from the matching event # fields — no `set:` list needed. defstruct [:name, balance: 0]
from IndexAutoMapAccountOpenedendimport { fromEvent, Guid } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@fromEvent(MbIndexAutoMapAccountOpened)export class MbIndexAutoMapMbAccountInfo { id: Guid = Guid.empty; @field(String) name = ''; // Automatically mapped from MbIndexAutoMapAccountOpened.name @field(Number) balance = 0; // Automatically mapped from MbIndexAutoMapAccountOpened.balance}Both approaches produce the same result. Model-bound projections with FromEvent are particularly concise when property names match between events and read models, providing the same automatic mapping that fluent projections get by default.
Reading Your Model-Bound Projections
Section titled “Reading Your Model-Bound Projections”Once you’ve defined a model-bound 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