Skip to content

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;

The read model we want is deliberately small:

public record ChoosingStyleBookStatus(
string Id,
string Title,
string Isbn,
bool IsBorrowed,
string? BorrowedBy);

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);

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));
}

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.

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
};
}

TypeScript note: the C# reducer also receives the EventContext (used above to read the event source ID into Id) — the TypeScript reducer’s handler methods only receive (event, currentState), with no context parameter, so the TypeScript example omits the Id field.

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.

StyleMental modelStrengthCost
Model-bound projectionThe 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 projectionA 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.
ReducerEvent 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.

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.