Choose a read-model style
Every read model answers the same question: “given the events so far, what should this screen or workflow read now?” Chronicle gives you more than one way to express that answer because different problems read better in different shapes.
Let’s keep one library read model up to date. A book is registered, borrowed, and returned:
using Cratis.Chronicle.Events;
[EventType]public record ChoosingStyleBookRegistered(string Title, string Isbn);
[EventType]public record ChoosingStyleBookBorrowed(string MemberName);
[EventType]public record ChoosingStyleBookReturned;import io.cratis.chronicle.events.EventType
@EventTypedata class ChoosingStyleBookRegistered(val title: String, val isbn: String)
@EventTypedata class ChoosingStyleBookBorrowed(val memberName: String)
@EventTypeclass ChoosingStyleBookReturnedimport io.cratis.chronicle.events.EventType;
@EventTyperecord ChoosingStyleBookRegistered(String title, String isbn) {}
@EventTyperecord ChoosingStyleBookBorrowed(String memberName) {}
@EventTyperecord ChoosingStyleBookReturned() {}defmodule MyApp.Events.ChoosingStyleBookRegistered do use Chronicle.Events.EventType, id: "choosing-style-book-registered"
defstruct [:title, :isbn]end
defmodule MyApp.Events.ChoosingStyleBookBorrowed do use Chronicle.Events.EventType, id: "choosing-style-book-borrowed"
defstruct [:member_name]end
defmodule MyApp.Events.ChoosingStyleBookReturned do use Chronicle.Events.EventType, id: "choosing-style-book-returned"
defstruct []endimport { eventType } from '@cratis/chronicle';
@eventType()class ChoosingStyleBookRegistered { title = ''; isbn = '';}
@eventType()class ChoosingStyleBookBorrowed { memberName = '';}
@eventType()class ChoosingStyleBookReturned {}The read model we want is deliberately small:
public record ChoosingStyleBookStatus( string Id, string Title, string Isbn, bool IsBorrowed, string? BorrowedBy);data class ChoosingStyleBookStatus( val id: String = "", val title: String = "", val isbn: String = "", val isBorrowed: Boolean = false, val borrowedBy: String? = null)class ChoosingStyleBookStatus { public String id = ""; public String title = ""; public String isbn = ""; public boolean isBorrowed = false; public String borrowedBy = null;}defmodule MyApp.ReadModels.ChoosingStyleBookStatus do defstruct id: "", title: "", isbn: "", is_borrowed: false, borrowed_by: nilendclass ChoosingStyleBookStatus { id = ''; title = ''; isbn = ''; isBorrowed = false; borrowedBy: string | null = null;}Model-bound projection: put the mapping on the model
Section titled “Model-bound projection: put the mapping on the model”For simple property mapping, model-bound projections are the shortest route. The read model declares how events populate it:
using Cratis.Chronicle.Keys;using Cratis.Chronicle.Projections.ModelBound;
public record ChoosingStyleBookStatusModelBound( [Key] string Id,
[SetFrom<ChoosingStyleBookRegistered>] string Title, [SetFrom<ChoosingStyleBookRegistered>] string Isbn,
[SetValue<ChoosingStyleBookBorrowed>(true)] [SetValue<ChoosingStyleBookReturned>(false)] bool IsBorrowed,
[SetFrom<ChoosingStyleBookBorrowed>(nameof(ChoosingStyleBookBorrowed.MemberName))] [SetValue<ChoosingStyleBookReturned>(null!)] // null! - SetValue's constructor takes non-nullable object string? BorrowedBy);Kotlin does not support this workflow yet.The model-bound `@SetFrom` annotation maps a property from an event, but there isno equivalent to C#'s `[SetValue<TEvent>(...)]` for assigning a constant value —so a flag like `isBorrowed` cannot be toggled per event type through model-boundattributes alone. Track the client SDK issue, or use a reducer instead.Java does not support this workflow yet.The model-bound `@SetFrom` annotation maps a property from an event, but there isno equivalent to C#'s `[SetValue<TEvent>(...)]` for assigning a constant value —so a flag like `isBorrowed` cannot be toggled per event type through model-boundattributes alone. Track the client SDK issue, or use a reducer instead.defmodule MyApp.ReadModels.ChoosingStyleBookStatusModelBound do use Chronicle.ReadModels.ReadModel
defstruct id: "", title: "", isbn: "", is_borrowed: false, borrowed_by: nil
from MyApp.Events.ChoosingStyleBookRegistered, set: [id: :event_source_id, title: :title, isbn: :isbn, is_borrowed: false, borrowed_by: nil]
from MyApp.Events.ChoosingStyleBookBorrowed, set: [is_borrowed: true, borrowed_by: :member_name]
from MyApp.Events.ChoosingStyleBookReturned, set: [is_borrowed: false, borrowed_by: nil]endimport { readModel, setFrom, setValue } from '@cratis/chronicle';
@readModel()class ChoosingStyleBookStatusModelBound { id = '';
@setFrom(ChoosingStyleBookRegistered, 'title') title = '';
@setFrom(ChoosingStyleBookRegistered, 'isbn') isbn = '';
@setValue(ChoosingStyleBookBorrowed, true) @setValue(ChoosingStyleBookReturned, false) isBorrowed = false;
@setFrom(ChoosingStyleBookBorrowed, 'memberName') @setValue(ChoosingStyleBookReturned, null) borrowedBy: string | null = null;}Chronicle discovers the projection from the attributes. There is no separate projection class, and the mapping sits directly next to the read model the UI or query will read. Reach for this first when the events set, add, subtract, count, or clear properties in a way the attributes can express.
Declarative projection: separate the mapping from the model
Section titled “Declarative projection: separate the mapping from the model”When the mapping needs to be more explicit, keep the read model clean and define the projection with
IProjectionFor<T>:
using Cratis.Chronicle.Projections;
public record ChoosingStyleBookStatusFluent( string Id, string Title, string Isbn, bool IsBorrowed, string? BorrowedBy);
public class ChoosingStyleBookStatusProjection : IProjectionFor<ChoosingStyleBookStatusFluent>{ public void Define(IProjectionBuilderFor<ChoosingStyleBookStatusFluent> builder) => builder .From<ChoosingStyleBookRegistered>(_ => _ .Set(m => m.Id).ToEventSourceId() .Set(m => m.Title).To(e => e.Title) .Set(m => m.Isbn).To(e => e.Isbn) .Set(m => m.IsBorrowed).ToValue(false) .Set(m => m.BorrowedBy).ToValue(null)) .From<ChoosingStyleBookBorrowed>(_ => _ .Set(m => m.IsBorrowed).ToValue(true) .Set(m => m.BorrowedBy).To(e => e.MemberName)) .From<ChoosingStyleBookReturned>(_ => _ .Set(m => m.IsBorrowed).ToValue(false) .Set(m => m.BorrowedBy).ToValue(null));}Kotlin does not support this workflow yet.The fluent `IProjectionBuilderFor`/`ISetBuilderFor` API supports `.to()`,`.toEventSourceId()`, and `.toProperty()`, but has no `.toValue()`-equivalent forassigning a constant value — so a flag like `isBorrowed` cannot be toggled perevent type through the declarative builder alone. Track the client SDK issue,or use a reducer instead.Java does not support this workflow yet.The fluent `IProjectionBuilderFor`/`ISetBuilderFor` API supports `.to()`,`.toEventSourceId()`, and `.toProperty()`, but has no `.toValue()`-equivalent forassigning a constant value — so a flag like `isBorrowed` cannot be toggled perevent type through the declarative builder alone. Track the client SDK issue,or use a reducer instead.defmodule MyApp.ReadModels.ChoosingStyleBookStatusFluent do use Chronicle.ReadModels.ReadModel
defstruct id: "", title: "", isbn: "", is_borrowed: false, borrowed_by: nilend
defmodule MyApp.Projections.ChoosingStyleBookStatusProjection do use Chronicle.Projections.Projection, model: MyApp.ReadModels.ChoosingStyleBookStatusFluent
from MyApp.Events.ChoosingStyleBookRegistered, set: [id: :event_source_id, title: :title, isbn: :isbn, is_borrowed: false, borrowed_by: nil]
from MyApp.Events.ChoosingStyleBookBorrowed, set: [is_borrowed: true, borrowed_by: :member_name]
from MyApp.Events.ChoosingStyleBookReturned, set: [is_borrowed: false, borrowed_by: nil]endimport { IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle';
class ChoosingStyleBookStatusFluent { id = ''; title = ''; isbn = ''; isBorrowed = false; borrowedBy: string | null = null;}
@projection()class ChoosingStyleBookStatusProjection implements IProjectionFor<ChoosingStyleBookStatusFluent> { define(builder: IProjectionBuilderFor<ChoosingStyleBookStatusFluent>): void { builder .from(ChoosingStyleBookRegistered, _ => _ .set(m => m.id).toEventSourceId() .set(m => m.title).to(e => e.title) .set(m => m.isbn).to(e => e.isbn) .set(m => m.isBorrowed).toValue(false) .set(m => m.borrowedBy).toValue(null)) .from(ChoosingStyleBookBorrowed, _ => _ .set(m => m.isBorrowed).toValue(true) .set(m => m.borrowedBy).to(e => e.memberName)) .from(ChoosingStyleBookReturned, _ => _ .set(m => m.isBorrowed).toValue(false) .set(m => m.borrowedBy).toValue(null)); }}This produces the same BookStatus documents as the model-bound version, but the trade-off is different:
more code, more room for explicit mapping, and the read model stays free of Chronicle attributes. Use it
when a projection needs joins, nested child mapping, event-context mapping, or naming/transformation
logic that would make attributes hard to scan.
Reducer: fold the events as code
Section titled “Reducer: fold the events as code”Reducers build read models too, but the shape is imperative: Chronicle passes the event and the current state into a method, and you return the next state.
using Cratis.Chronicle.Events;using Cratis.Chronicle.Reducers;
public record ChoosingStyleBookStatusReducerModel( string Id, string Title, string Isbn, bool IsBorrowed, string? BorrowedBy);
public class ChoosingStyleBookStatusReducer : IReducerFor<ChoosingStyleBookStatusReducerModel>{ public ChoosingStyleBookStatusReducerModel OnBookRegistered( ChoosingStyleBookRegistered @event, ChoosingStyleBookStatusReducerModel? current, EventContext context) => new( Id: context.EventSourceId.Value, Title: @event.Title, Isbn: @event.Isbn, IsBorrowed: false, BorrowedBy: null);
public ChoosingStyleBookStatusReducerModel OnBookBorrowed( ChoosingStyleBookBorrowed @event, ChoosingStyleBookStatusReducerModel? current, EventContext context) => current! with { IsBorrowed = true, BorrowedBy = @event.MemberName };
public ChoosingStyleBookStatusReducerModel OnBookReturned( ChoosingStyleBookReturned @event, ChoosingStyleBookStatusReducerModel? current, EventContext context) => current! with { IsBorrowed = false, BorrowedBy = null };}import io.cratis.chronicle.observation.Reducer
data class ChoosingStyleBookStatusReducerModel( val title: String = "", val isbn: String = "", val isBorrowed: Boolean = false, val borrowedBy: String? = null)
@Reducerclass ChoosingStyleBookStatusReducer { fun choosingStyleBookRegistered(event: ChoosingStyleBookRegistered): ChoosingStyleBookStatusReducerModel = ChoosingStyleBookStatusReducerModel( title = event.title, isbn = event.isbn, isBorrowed = false, borrowedBy = null )
fun choosingStyleBookBorrowed( event: ChoosingStyleBookBorrowed, current: ChoosingStyleBookStatusReducerModel ): ChoosingStyleBookStatusReducerModel = current.copy(isBorrowed = true, borrowedBy = event.memberName)
fun choosingStyleBookReturned( event: ChoosingStyleBookReturned, current: ChoosingStyleBookStatusReducerModel ): ChoosingStyleBookStatusReducerModel = current.copy(isBorrowed = false, borrowedBy = null)}import io.cratis.chronicle.observation.Reducer;
record ChoosingStyleBookStatusReducerModel( String title, String isbn, boolean isBorrowed, String borrowedBy) {}
@Reducerclass ChoosingStyleBookStatusReducer { ChoosingStyleBookStatusReducerModel choosingStyleBookRegistered(ChoosingStyleBookRegistered event) { return new ChoosingStyleBookStatusReducerModel(event.title(), event.isbn(), false, null); }
ChoosingStyleBookStatusReducerModel choosingStyleBookBorrowed( ChoosingStyleBookBorrowed event, ChoosingStyleBookStatusReducerModel current) { return new ChoosingStyleBookStatusReducerModel( current.title(), current.isbn(), true, event.memberName()); }
ChoosingStyleBookStatusReducerModel choosingStyleBookReturned( ChoosingStyleBookReturned event, ChoosingStyleBookStatusReducerModel current) { return new ChoosingStyleBookStatusReducerModel( current.title(), current.isbn(), false, null); }}defmodule MyApp.ReadModels.ChoosingStyleBookStatusReducerModel do defstruct title: "", isbn: "", is_borrowed: false, borrowed_by: nilend
defmodule MyApp.Reducers.ChoosingStyleBookStatusReducer do use Chronicle.Reducers.Reducer, model: MyApp.ReadModels.ChoosingStyleBookStatusReducerModel
alias MyApp.Events.{ ChoosingStyleBookBorrowed, ChoosingStyleBookRegistered, ChoosingStyleBookReturned }
@handles ChoosingStyleBookRegistered @handles ChoosingStyleBookBorrowed @handles ChoosingStyleBookReturned
@impl true def reduce(%ChoosingStyleBookRegistered{} = event, _model, _context) do %MyApp.ReadModels.ChoosingStyleBookStatusReducerModel{ title: event.title, isbn: event.isbn, is_borrowed: false, borrowed_by: nil } end
def reduce(%ChoosingStyleBookBorrowed{} = event, model, _context) do %{model | is_borrowed: true, borrowed_by: event.member_name} end
def reduce(%ChoosingStyleBookReturned{}, model, _context) do %{model | is_borrowed: false, borrowed_by: nil} endendimport { reducer } from '@cratis/chronicle';
class ChoosingStyleBookStatusReducerModel { title = ''; isbn = ''; isBorrowed = false; borrowedBy: string | null = null;}
// Handler methods receive only the event and the current state - there is no// event-context parameter, unlike the C# reducer's EventContext argument.// The method name must be the exact camelCase of the event's class name -// Chronicle discovers handlers by name, not by parameter type.@reducer('', undefined, ChoosingStyleBookStatusReducerModel)class ChoosingStyleBookStatusReducer { choosingStyleBookRegistered( event: ChoosingStyleBookRegistered, current: ChoosingStyleBookStatusReducerModel | undefined ): ChoosingStyleBookStatusReducerModel { return { title: event.title, isbn: event.isbn, isBorrowed: false, borrowedBy: null }; }
choosingStyleBookBorrowed( event: ChoosingStyleBookBorrowed, current: ChoosingStyleBookStatusReducerModel ): ChoosingStyleBookStatusReducerModel { return { ...current, isBorrowed: true, borrowedBy: event.memberName }; }
choosingStyleBookReturned( event: ChoosingStyleBookReturned, current: ChoosingStyleBookStatusReducerModel ): ChoosingStyleBookStatusReducerModel { return { ...current, isBorrowed: false, borrowedBy: null }; }}TypeScript note: the C# reducer also receives the
EventContext(used above to read the event source ID intoId) — the TypeScript reducer’s handler methods only receive(event, currentState), with no context parameter, so the TypeScript example omits theIdfield.
This is still the same read model, but the expression is a fold over state rather than a projection definition. Reach for a reducer when the logic is easier to read as C#: branching, derived values, totals with guard logic, temporal state, or calculations that span several previous events.
How they differ
Section titled “How they differ”| Style | Mental model | Strength | Cost |
|---|---|---|---|
| Model-bound projection | The read model declares how events fill its properties. | Least code; best default for straightforward screen models. | Attributes can get dense when the mapping becomes complex. |
| Declarative projection | A separate projection definition maps events to the read model. | Explicit mapping, joins, nested structures, transformations, and a clean read model type. | More ceremony than attributes. |
| Reducer | Event plus current state returns next state. | Complex calculations and temporal logic read naturally as code. | More responsibility: handle missing current state and keep the reducer pure. |
Use the simplest style that keeps the intent obvious. Model-bound first, declarative when the mapping needs structure, reducer when the calculation reads better as a state transition.
Testing the choice
Section titled “Testing the choice”You can exercise all three styles with the same
read-model scenario. The scenario discovers a model-bound
projection, an IProjectionFor<T>, or an IReducerFor<T> for the read model type and runs the events in
memory. That makes style changes cheap: rewrite the projection as a reducer, keep the same events and
expected BookStatus, and the test tells you whether the behavior stayed the same.