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
@EventType(id = "index-account-opened")data 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;
@EventType(id = "index-account-opened")record 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, readModel, setFrom } from '@cratis/chronicle';
@eventType()class MbIndexAccountOpened { name = ''; initialBalance = 0;}
@readModel()@fromEvent(MbIndexAccountOpened)class MbIndexAccountInfo { id: Guid = Guid.empty; 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:
- Any class/record level projection mapping attributes (FromEvent, Passive, NotRewindable, etc.)
- Any projection mapping attribute (SetFrom, AddFrom, Join, etc.)
No explicit marker attribute is required on the type itself.
Key Features
Section titled “Key Features”Model-bound projections support all projection engine capabilities:
- 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: FromEventSequence, NotRewindable, Passive
- 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
- 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
When to Use
Section titled “When to Use”Prefer model-bound projections by default:
- They keep projection metadata next to the read model
- They cover the common Chronicle projection capabilities without a separate class
- They make the happy path concise and consistent across features
Use fluent projections (IProjectionFor<T>) as a fallback when the projection cannot be expressed cleanly with model-bound attributes:
- You need projection construction that is more dynamic or procedural
- You intentionally want to separate the read model from the projection definition
- You need specialized builder features that do not map naturally to attributes
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.Name).To(e => e.Name) .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
@EventType(id = "index-explicit-account-opened")data 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") } }}Java does not support this workflow yet.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.SetFrom
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;
@EventType(id = "index-explicit-account-opened")record IndexExplicitAccountOpened(String name, double initialBalance) {}
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 with AutoMap:
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 .AutoMap() .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
@EventType(id = "index-automap-account-opened")data 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;import kotlin.jvm.JvmClassMappingKt;
@EventType(id = "index-automap-account-opened")record 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(JvmClassMappingKt.getKotlinClass(IndexAutoMapAccountOpened.class), null); }}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, readModel } from '@cratis/chronicle';
@readModel()@fromEvent(MbIndexAutoMapAccountOpened)class MbIndexAutoMapMbAccountInfo { id: Guid = Guid.empty; name = ''; // Automatically mapped from MbIndexAutoMapAccountOpened.name 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 benefits as .AutoMap() in fluent projections.
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 with strong consistency
- 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