Choose how to build a read model
Your library screen needs a book’s title and current borrower. The events tell you that the book was registered, borrowed, and returned, but the screen needs one answer: given the events so far, what should this view read now? You should not have to write a fold just to copy a title, or squeeze a state-dependent calculation into mapping attributes.
Chronicle offers two families. Projections describe mappings that the kernel executes: attributes
on the model, a fluent IProjectionFor<T> definition, or Projection Declaration Language (PDL).
Reducers run your code, passing each event and the previous state to a method that returns the
next state: static Reduce methods on the model, a separate IReducerFor<T>, or an explicitly
nominated plain class. Authoring placement changes within each family; moving between families also
changes execution and capabilities.
Which one should I pick?
Section titled “Which one should I pick?”For application code, follow this order:
- Start with model-bound attributes when the mapping reads clearly on the model.
- Use fluent
IProjectionFor<T>when the mapping needs structure or the model should stay attribute-free. - Choose a reducer when the answer reads better as state-transition code. For new code, start with a self-reducing read model. Use a separate
IReducerFor<T>when you need fold-time dependency injection, instance state used during replay, or separation from the model. - Nominate a plain reducer class only when the reducer or contract assembly cannot reference Chronicle.
Use PDL when the definition belongs outside application code, such as the Workbench, Screenplay, or an ad-hoc query. It is another projection input, not the code-first default.
Match the choice to your task
Section titled “Match the choice to your task”| You need… | Pick | Why |
|---|---|---|
| A screen showing a book’s title and current borrower | Model-bound attributes | The events directly set or clear properties. Keep those mappings next to the shape the screen reads. |
| A dashboard counting loans or summing amounts | A projection; attributes first | Counting, incrementing, adding, and subtracting are built-in operations. For one total across event sources, use a constant key; arithmetic alone does not require a reducer. |
| A library with a collection of books | Fluent projection when the attributes become hard to scan | Children describe child identity, parent routing, updates, and removal. Attributes support children too. |
| A book view with a member’s details from another event source | A projection, often fluent | A join correlates events across sources and updates the view. Reducers fold one event source at a time and have no native join operation. |
| Composite keys, context fields, or an attribute-free model | Fluent projection | The builder makes key structure and context mappings explicit without decorating the model. |
| A status that depends on the previous status and a rule | Self-reducing model | Branch on current and return the next state. The rule and state live together. |
| A running balance that ignores a debit under a guard | Self-reducing model | Read the previous balance, apply the guard, and calculate the result in code. This derives a view; it does not reject the already-appended debit. |
| A fold that needs a currency-rate service or clock | Separate IReducerFor<T> | Constructor injection is available during the fold. Static Reduce accepts only event, current, and optional context. Prefer recording the rate or time in the event: consulting today’s service during replay can change yesterday’s result. |
| A model distributed as a shared contract | Separate IReducerFor<T> for stateful logic; fluent projection for mapping | Keep behavior in the consuming application without adding transitions or mapping attributes to the contract. |
| An observer assembly that cannot reference Chronicle | Nominated plain class | Register it at the composition root and let Chronicle infer its one model from supported instance methods. Nomination does not relax signature validation. |
| An Arc application with queries and state transitions on one record | Self-reducing model with [ReadModel] and static query methods | Keep the state, static Reduce methods, and query surface together. Apply the version guidance below so reduction does not become an endpoint. |
| State a command must read on demand | A passive model | [Passive] on the model, or .Passive() on a fluent projection, avoids materialization lag. For a decision that must remain valid until append, use decision reads only with an admitted projection or reducer shape. A fresh passive reducer read does not protect the decision from a concurrent append. |
| A definition edited in the Workbench or authored in Screenplay | PDL | The definition is text owned by the tooling workflow. Check Chronicle’s supported expression subset and save settings. |
One library view, four implementations
Section titled “One library view, four implementations”Compare the forms using the same events. The snippets are alternatives in a Chronicle-enabled application with default artifact discovery, not four builders to attach to one model.
using Cratis.Chronicle.Compliance.GDPR;using Cratis.Chronicle.Events;
[EventType]public record ChoosingStyleBookRegistered(string Title, string Isbn);
[EventType]public record ChoosingStyleBookBorrowed([PII] string MemberName);
[EventType]public record ChoosingStyleBookReturned;import io.cratis.chronicle.compliance.Piiimport io.cratis.chronicle.events.EventType
@EventTypedata class ChoosingStyleBookRegistered(val title: String, val isbn: String)
@EventTypedata class ChoosingStyleBookBorrowed(@property:Pii val memberName: String)
@EventTypeclass ChoosingStyleBookReturnedimport io.cratis.chronicle.compliance.Pii;import io.cratis.chronicle.events.EventType;
@EventTyperecord ChoosingStyleBookRegistered(String title, String isbn) {}
@EventTyperecord ChoosingStyleBookBorrowed(@Pii 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]
pii :member_nameend
defmodule MyApp.Events.ChoosingStyleBookReturned do use Chronicle.Events.EventType, id: "choosing-style-book-returned"
defstruct []endimport { eventType, pii } from '@cratis/chronicle';
@eventType()class ChoosingStyleBookRegistered { title = ''; isbn = '';}
@eventType()class ChoosingStyleBookBorrowed { @pii() memberName = '';}
@eventType()class ChoosingStyleBookReturned {}The view has the following fields. Title and Isbn are nullable because a loan event may arrive
without a registration in this history.
using Cratis.Chronicle.Compliance.GDPR;
public record ChoosingStyleBookStatus( string Id, string? Title, string? Isbn, bool IsBorrowed, [PII] string? BorrowedBy);import io.cratis.chronicle.compliance.Pii
data class ChoosingStyleBookStatus( val id: String = "", val title: String? = null, val isbn: String? = null, val isBorrowed: Boolean = false, @property:Pii val borrowedBy: String? = null)import io.cratis.chronicle.compliance.Pii;
class ChoosingStyleBookStatus { public String id = ""; public String title = null; public String isbn = null; public boolean isBorrowed = false; @Pii public String borrowedBy = null;}defmodule MyApp.ReadModels.ChoosingStyleBookStatus do use Chronicle.ReadModels.ReadModel
defstruct id: nil, title: nil, isbn: nil, is_borrowed: false, borrowed_by: nil
pii :borrowed_byendimport { pii } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
class ChoosingStyleBookStatus { id = ''; @field(String) title: string | null = null; @field(String) isbn: string | null = null; isBorrowed = false; @pii() @field(String) borrowedBy: string | null = null;}These examples deliberately define registration as a reset of loan state. Borrowing sets the borrower; returning clears it. Borrowing or returning without prior state creates a partial view with the book’s ID and no title or ISBN. This is a view policy, not an assertion that such a loan is allowed.
Model-bound: describe the mapping on the model
Section titled “Model-bound: describe the mapping on the model”using Cratis.Chronicle.Compliance.GDPR;using Cratis.Chronicle.Keys;using Cratis.Chronicle.Projections.ModelBound;
public record ChoosingStyleBookStatusModelBound( [Key] string Id,
[SetFrom<ChoosingStyleBookRegistered>] string? Title, [SetFrom<ChoosingStyleBookRegistered>] string? Isbn,
[SetValue<ChoosingStyleBookRegistered>(false)] [SetValue<ChoosingStyleBookBorrowed>(true)] [SetValue<ChoosingStyleBookReturned>(false)] bool IsBorrowed,
[PII] [SetFrom<ChoosingStyleBookBorrowed>(nameof(ChoosingStyleBookBorrowed.MemberName))] [ClearWith<ChoosingStyleBookRegistered>] [ClearWith<ChoosingStyleBookReturned>] string? BorrowedBy);import io.cratis.chronicle.compliance.Piiimport io.cratis.chronicle.projections.FromEventimport io.cratis.chronicle.projections.SetFromimport io.cratis.chronicle.projections.SetFromContextimport io.cratis.chronicle.projections.SetValue
@FromEvent(ChoosingStyleBookRegistered::class)@FromEvent(ChoosingStyleBookBorrowed::class)@FromEvent(ChoosingStyleBookReturned::class)data class ChoosingStyleBookStatusModelBound( @SetFromContext(ChoosingStyleBookRegistered::class, "eventSourceId") @SetFromContext(ChoosingStyleBookBorrowed::class, "eventSourceId") @SetFromContext(ChoosingStyleBookReturned::class, "eventSourceId") val id: String = "",
@SetFrom("title", ChoosingStyleBookRegistered::class) val title: String? = null,
@SetFrom("isbn", ChoosingStyleBookRegistered::class) val isbn: String? = null,
@SetValue(ChoosingStyleBookRegistered::class, value = "false") @SetValue(ChoosingStyleBookBorrowed::class, value = "true") @SetValue(ChoosingStyleBookReturned::class, value = "false") val isBorrowed: Boolean = false,
@property:Pii @SetFrom("memberName", ChoosingStyleBookBorrowed::class) @SetValue(ChoosingStyleBookRegistered::class, clear = true) @SetValue(ChoosingStyleBookReturned::class, clear = true) val borrowedBy: String? = null)import io.cratis.chronicle.compliance.Pii;import io.cratis.chronicle.projections.FromEvent;import io.cratis.chronicle.projections.SetFrom;import io.cratis.chronicle.projections.SetFromContext;import io.cratis.chronicle.projections.SetValue;
@FromEvent(eventType = ChoosingStyleBookRegistered.class)@FromEvent(eventType = ChoosingStyleBookBorrowed.class)@FromEvent(eventType = ChoosingStyleBookReturned.class)class ChoosingStyleBookStatusModelBound { @SetFromContext(eventType = ChoosingStyleBookRegistered.class, contextProperty = "eventSourceId") @SetFromContext(eventType = ChoosingStyleBookBorrowed.class, contextProperty = "eventSourceId") @SetFromContext(eventType = ChoosingStyleBookReturned.class, contextProperty = "eventSourceId") public String id = "";
@SetFrom(propertyPath = "title", eventType = ChoosingStyleBookRegistered.class) public String title = null;
@SetFrom(propertyPath = "isbn", eventType = ChoosingStyleBookRegistered.class) public String isbn = null;
@SetValue(eventType = ChoosingStyleBookRegistered.class, value = "false") @SetValue(eventType = ChoosingStyleBookBorrowed.class, value = "true") @SetValue(eventType = ChoosingStyleBookReturned.class, value = "false") public boolean isBorrowed = false;
@Pii @SetFrom(propertyPath = "memberName", eventType = ChoosingStyleBookBorrowed.class) @SetValue(eventType = ChoosingStyleBookRegistered.class, clear = true) @SetValue(eventType = ChoosingStyleBookReturned.class, clear = true) public String borrowedBy = null;}defmodule MyApp.ReadModels.ChoosingStyleBookStatusModelBound do use Chronicle.ReadModels.ReadModel
defstruct id: nil, title: nil, isbn: nil, is_borrowed: false, borrowed_by: nil
pii :borrowed_by
from MyApp.Events.ChoosingStyleBookRegistered, set: [id: :event_source_id, title: :title, isbn: :isbn, is_borrowed: false], clear: [:borrowed_by]
from MyApp.Events.ChoosingStyleBookBorrowed, set: [id: :event_source_id, is_borrowed: true, borrowed_by: :member_name]
from MyApp.Events.ChoosingStyleBookReturned, set: [id: :event_source_id, is_borrowed: false], clear: [:borrowed_by]endimport { pii, setFrom, setFromContext, setValue } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
export class ChoosingStyleBookStatusModelBound { @setFromContext(ChoosingStyleBookRegistered, 'eventSourceId') @setFromContext(ChoosingStyleBookBorrowed, 'eventSourceId') @setFromContext(ChoosingStyleBookReturned, 'eventSourceId') id = '';
@setFrom(ChoosingStyleBookRegistered, 'title') @field(String) title: string | null = null;
@setFrom(ChoosingStyleBookRegistered, 'isbn') @field(String) isbn: string | null = null;
@setValue(ChoosingStyleBookRegistered, false) @setValue(ChoosingStyleBookBorrowed, true) @setValue(ChoosingStyleBookReturned, false) isBorrowed = false;
@pii() @setFrom(ChoosingStyleBookBorrowed, 'memberName') @setValue(ChoosingStyleBookRegistered, null) @setValue(ChoosingStyleBookReturned, null) @field(String) borrowedBy: string | null = null;}Chronicle discovers the mapping attributes and builds a projection definition. Registration sets the bibliographic fields and resets both loan fields. The explicit clear matters when a book is registered again after a loan; an omitted mapping would leave the old borrower in place.
Fluent: keep the definition separate
Section titled “Fluent: keep the definition separate”using Cratis.Chronicle.Compliance.GDPR;using Cratis.Chronicle.Projections;
public record ChoosingStyleBookStatusFluent( string Id, string? Title, string? Isbn, bool IsBorrowed, [PII] string? BorrowedBy);
public class ChoosingStyleBookStatusProjection : IProjectionFor<ChoosingStyleBookStatusFluent>{ public void Define(IProjectionBuilderFor<ChoosingStyleBookStatusFluent> builder) => builder .From<ChoosingStyleBookRegistered>(_ => _ .Set(m => m.Id).ToEventSourceId() .Set(m => m.IsBorrowed).ToValue(false) .Clear(m => m.BorrowedBy)) .From<ChoosingStyleBookBorrowed>(_ => _ .Set(m => m.Id).ToEventSourceId() .Set(m => m.IsBorrowed).ToValue(true) .Set(m => m.BorrowedBy).To(e => e.MemberName)) .From<ChoosingStyleBookReturned>(_ => _ .Set(m => m.Id).ToEventSourceId() .Set(m => m.IsBorrowed).ToValue(false) .Clear(m => m.BorrowedBy));}// Requires io.cratis:chronicle 6.5.0 or later.import io.cratis.chronicle.projections.IProjectionBuilderForimport io.cratis.chronicle.projections.IProjectionFor
class ChoosingStyleBookStatusProjection : IProjectionFor<ChoosingStyleBookStatus> { override fun define(builder: IProjectionBuilderFor<ChoosingStyleBookStatus>) { builder .from(ChoosingStyleBookRegistered::class) { it.set(ChoosingStyleBookStatus::id).toEventSourceId() it.set(ChoosingStyleBookStatus::title).toProperty("title") it.set(ChoosingStyleBookStatus::isbn).toProperty("isbn") it.set(ChoosingStyleBookStatus::isBorrowed).toValue(false) it.set(ChoosingStyleBookStatus::borrowedBy).toValue(null) } .from(ChoosingStyleBookBorrowed::class) { it.set(ChoosingStyleBookStatus::id).toEventSourceId() it.set(ChoosingStyleBookStatus::isBorrowed).toValue(true) it.set(ChoosingStyleBookStatus::borrowedBy).toProperty("memberName") } .from(ChoosingStyleBookReturned::class) { it.set(ChoosingStyleBookStatus::id).toEventSourceId() it.set(ChoosingStyleBookStatus::isBorrowed).toValue(false) it.set(ChoosingStyleBookStatus::borrowedBy).toValue(null) } }}// Requires io.cratis:chronicle 6.5.0 or later.import io.cratis.chronicle.projections.IProjectionBuilderFor;import io.cratis.chronicle.projections.IProjectionFor;
class ChoosingStyleBookStatusProjection implements IProjectionFor<ChoosingStyleBookStatus> { @Override public void define(IProjectionBuilderFor<ChoosingStyleBookStatus> builder) { builder .from(ChoosingStyleBookRegistered.class, fb -> { fb.<String>set("id").toEventSourceId(); fb.<String>set("title").toProperty("title"); fb.<String>set("isbn").toProperty("isbn"); fb.<Boolean>set("isBorrowed").toValue(false); fb.<String>set("borrowedBy").toValue(null); }) .from(ChoosingStyleBookBorrowed.class, fb -> { fb.<String>set("id").toEventSourceId(); fb.<Boolean>set("isBorrowed").toValue(true); fb.<String>set("borrowedBy").toProperty("memberName"); }) .from(ChoosingStyleBookReturned.class, fb -> { fb.<String>set("id").toEventSourceId(); fb.<Boolean>set("isBorrowed").toValue(false); fb.<String>set("borrowedBy").toValue(null); }); }}defmodule MyApp.ReadModels.ChoosingStyleBookStatusFluent do use Chronicle.ReadModels.ReadModel
defstruct id: nil, title: nil, isbn: nil, is_borrowed: false, borrowed_by: nil
pii :borrowed_byend
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], clear: [:borrowed_by]
from MyApp.Events.ChoosingStyleBookBorrowed, set: [id: :event_source_id, is_borrowed: true, borrowed_by: :member_name]
from MyApp.Events.ChoosingStyleBookReturned, set: [id: :event_source_id, is_borrowed: false], clear: [:borrowed_by]endimport { IProjectionBuilderFor, IProjectionFor, pii, projection } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
class ChoosingStyleBookStatusFluent { id = ''; @field(String) title: string | null = null; @field(String) isbn: string | null = null; isBorrowed = false; @pii() @field(String) borrowedBy: string | null = null;}
@projection('', ChoosingStyleBookStatusFluent)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.id).toEventSourceId() .set(m => m.isBorrowed).toValue(true) .set(m => m.borrowedBy).to(e => e.memberName)) .from(ChoosingStyleBookReturned, _ => _ .set(m => m.id).toEventSourceId() .set(m => m.isBorrowed).toValue(false) .set(m => m.borrowedBy).toValue(null)); }}AutoMap supplies Title and Isbn from registration without an .AutoMap() call. The other mappings
set the ID and loan fields explicitly. Define constructs a definition once for registration; these
lambdas are not service-injected handlers called for each arriving event.
This is still a kernel projection. Move to fluent for a clearer definition or richer mapping structure, not to run arbitrary business code inside event processing.
Self-reducing: keep transitions with the state
Section titled “Self-reducing: keep transitions with the state”using Cratis.Chronicle.Compliance.GDPR;using Cratis.Chronicle.Events;
public record ChoosingStyleBookStatusSelfReducing( string Id, string? Title, string? Isbn, bool IsBorrowed, [PII] string? BorrowedBy){ public static ChoosingStyleBookStatusSelfReducing Reduce( ChoosingStyleBookRegistered @event, ChoosingStyleBookStatusSelfReducing? current, EventContext context) => new(context.EventSourceId.Value, @event.Title, @event.Isbn, false, null);
public static ChoosingStyleBookStatusSelfReducing Reduce( ChoosingStyleBookBorrowed @event, ChoosingStyleBookStatusSelfReducing? current, EventContext context) => (current ?? new(context.EventSourceId.Value, null, null, false, null)) with { IsBorrowed = true, BorrowedBy = @event.MemberName };
public static ChoosingStyleBookStatusSelfReducing Reduce( ChoosingStyleBookReturned @event, ChoosingStyleBookStatusSelfReducing? current, EventContext context) => (current ?? new(context.EventSourceId.Value, null, null, false, null)) with { IsBorrowed = false, BorrowedBy = null };}Kotlin does not support this workflow yet.Track self-reducing read model support: https://github.com/Cratis/Chronicle/issues/4851Java does not support this workflow yet.Track self-reducing read model support: https://github.com/Cratis/Chronicle/issues/4851This Chronicle client does not support this workflow yet.Track https://github.com/Cratis/Chronicle/issues/4851 before using self-reducing read models from Elixir.TypeScript does not support this workflow yet.Track https://github.com/Cratis/Chronicle/issues/4851 before using this API from this language.The complete public static Reduce shape is the discovery signal. Chronicle needs no model instance
or reducer marker to invoke it. Each handler accepts nullable current state, including the loan and
return handlers: either event can be the first event seen, or follow removal.
Separate reducer: put the fold on an instance
Section titled “Separate reducer: put the fold on an instance”using Cratis.Chronicle.Compliance.GDPR;using Cratis.Chronicle.Events;using Cratis.Chronicle.Reducers;
public record ChoosingStyleBookStatusReducerModel( string Id, string? Title, string? Isbn, bool IsBorrowed, [PII] 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 ?? new(context.EventSourceId.Value, null, null, false, null)) with { IsBorrowed = true, BorrowedBy = @event.MemberName };
public ChoosingStyleBookStatusReducerModel OnBookReturned( ChoosingStyleBookReturned @event, ChoosingStyleBookStatusReducerModel? current, EventContext context) => (current ?? new(context.EventSourceId.Value, null, null, false, null)) with { IsBorrowed = false, BorrowedBy = null };}import io.cratis.chronicle.compliance.Piiimport io.cratis.chronicle.events.EventContextimport io.cratis.chronicle.observation.Reducer
data class ChoosingStyleBookStatusReducerModel( val id: String, val title: String? = null, val isbn: String? = null, val isBorrowed: Boolean = false, @property:Pii val borrowedBy: String? = null)
@Reducerclass ChoosingStyleBookStatusReducer { fun choosingStyleBookRegistered( event: ChoosingStyleBookRegistered, current: ChoosingStyleBookStatusReducerModel?, context: EventContext ): ChoosingStyleBookStatusReducerModel = ChoosingStyleBookStatusReducerModel( id = context.eventSourceId, title = event.title, isbn = event.isbn, isBorrowed = false, borrowedBy = null )
fun choosingStyleBookBorrowed( event: ChoosingStyleBookBorrowed, current: ChoosingStyleBookStatusReducerModel?, context: EventContext ): ChoosingStyleBookStatusReducerModel = (current ?: ChoosingStyleBookStatusReducerModel(context.eventSourceId)) .copy(isBorrowed = true, borrowedBy = event.memberName)
fun choosingStyleBookReturned( event: ChoosingStyleBookReturned, current: ChoosingStyleBookStatusReducerModel?, context: EventContext ): ChoosingStyleBookStatusReducerModel = (current ?: ChoosingStyleBookStatusReducerModel(context.eventSourceId)) .copy(isBorrowed = false, borrowedBy = null)}import io.cratis.chronicle.compliance.Pii;import io.cratis.chronicle.events.EventContext;import io.cratis.chronicle.observation.Reducer;
record ChoosingStyleBookStatusReducerModel( String id, String title, String isbn, boolean isBorrowed, @Pii String borrowedBy) {}
@Reducerclass ChoosingStyleBookStatusReducer { public ChoosingStyleBookStatusReducerModel choosingStyleBookRegistered( ChoosingStyleBookRegistered event, ChoosingStyleBookStatusReducerModel current, EventContext context) { return new ChoosingStyleBookStatusReducerModel( context.getEventSourceId(), event.title(), event.isbn(), false, null); }
public ChoosingStyleBookStatusReducerModel choosingStyleBookBorrowed( ChoosingStyleBookBorrowed event, ChoosingStyleBookStatusReducerModel current, EventContext context) { if (current == null) { current = new ChoosingStyleBookStatusReducerModel( context.getEventSourceId(), null, null, false, null); } return new ChoosingStyleBookStatusReducerModel( current.id(), current.title(), current.isbn(), true, event.memberName()); }
public ChoosingStyleBookStatusReducerModel choosingStyleBookReturned( ChoosingStyleBookReturned event, ChoosingStyleBookStatusReducerModel current, EventContext context) { if (current == null) { current = new ChoosingStyleBookStatusReducerModel( context.getEventSourceId(), null, null, false, null); } return new ChoosingStyleBookStatusReducerModel( current.id(), current.title(), current.isbn(), false, null); }}defmodule MyApp.ReadModels.ChoosingStyleBookStatusReducerModel do use Chronicle.ReadModels.ReadModel
defstruct id: nil, title: nil, isbn: nil, is_borrowed: false, borrowed_by: nil
pii :borrowed_byend
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{ id: context.event_source_id, title: event.title, isbn: event.isbn, is_borrowed: false, borrowed_by: nil } end
def reduce(%ChoosingStyleBookBorrowed{} = event, model, context) do model = model || %MyApp.ReadModels.ChoosingStyleBookStatusReducerModel{id: context.event_source_id} %{model | is_borrowed: true, borrowed_by: event.member_name} end
def reduce(%ChoosingStyleBookReturned{}, model, context) do model = model || %MyApp.ReadModels.ChoosingStyleBookStatusReducerModel{id: context.event_source_id} %{model | is_borrowed: false, borrowed_by: nil} endendimport { EventContext, pii, reducer } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
class ChoosingStyleBookStatusReducerModel { id = ''; @field(String) title: string | null = null; @field(String) isbn: string | null = null; isBorrowed = false; @pii() @field(String) borrowedBy: string | null = null;}
// Handler methods receive the event, the current state, and EventContext// as a third argument; the context supplies the event source id.// 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, context: EventContext ): ChoosingStyleBookStatusReducerModel { return { id: context.eventSourceId, title: event.title, isbn: event.isbn, isBorrowed: false, borrowedBy: null }; }
choosingStyleBookBorrowed( event: ChoosingStyleBookBorrowed, current: ChoosingStyleBookStatusReducerModel | undefined, context: EventContext ): ChoosingStyleBookStatusReducerModel { return { ...(current ?? new ChoosingStyleBookStatusReducerModel()), id: context.eventSourceId, isBorrowed: true, borrowedBy: event.memberName }; }
choosingStyleBookReturned( event: ChoosingStyleBookReturned, current: ChoosingStyleBookStatusReducerModel | undefined, context: EventContext ): ChoosingStyleBookStatusReducerModel { return { ...(current ?? new ChoosingStyleBookStatusReducerModel()), id: context.eventSourceId, isBorrowed: false, borrowedBy: null }; }}The same transitions now live in an IReducerFor<T> class. Chronicle activates that class through its
service provider. This extra type is useful when the fold needs constructor dependencies or the model
must remain a data contract. A plain nominated reducer uses the same instance fold, with its model
inferred at explicit registration.
Check the results
Section titled “Check the results”For event source book-42, all four implementations produce these field values:
| Event history | Title / ISBN | Borrowed? | Borrower |
|---|---|---|---|
| No events | No instance | — | — |
| Registered(“Dune”, “9780441172719”) | Dune / 9780441172719 | false | not set |
| Registered → Borrowed(“Avery”) | Dune / 9780441172719 | true | Avery |
| Registered → Borrowed → Returned | Dune / 9780441172719 | false | not set |
| Registered → Borrowed → Registered(“Dune”, “9780441172719”) | Dune / 9780441172719 | false | not set |
| Borrowed(“Avery”) without registration | not set | true | Avery |
| Returned without registration | not set | false | not set |
“Not set” means the field holds no value; each client shows that in its own way. Id is book-42 in every existing instance. The snippets use distinct model type names so they can
compile together; those names give them different registered identifiers and default containers.
Equivalent field values do not make them interchangeable persisted documents.
The borrower’s name is personal data. The snippets mark both the event member and the destination member as personal data; a mapping does not transfer compliance annotations. In a real library, select the member as the append’s subject rather than accepting the book ID fallback. Review read-model compliance before deploying: projections retain per-property subject lineage, while reducer protection uses the batch’s default subject resolved from its first event. Output equality alone does not prove equal subject ownership.
Capabilities that change the choice
Section titled “Capabilities that change the choice”The API names here follow the .NET examples. Consult each page’s client tabs for its authoring shape.
The separate column covers both IReducerFor<T> and a nominated plain instance reducer.
| Capability | Attributes | Fluent | PDL | Self reducer | Separate reducer |
|---|---|---|---|---|---|
| Logic location | Model metadata | Separate Define class | Declaration text | Static model methods | Instance methods on another class |
| Dependency injection during fold | No | No; DI can build the definition | No | No | Yes |
| Joins across event sources | Yes | Yes | Yes | No native joins | No native joins |
| Children / nested objects | Mappings | Mappings | Blocks | Your code | Your code |
| Counters and sums | Operations; no dynamic dictionary-counter form | Operations, including dynamic dictionary counters | Operations | Your code, per source | Your code, per source |
| Remove an instance | Removal attributes | Removal builders | Removal blocks | Return null | Return null |
| Variant groups | VariantOf / EntersOn / GlobalFor | VariantOf / EntersOn; no GlobalFor equivalent | No native group block | No native group wiring | No native group wiring |
| Passive reads | [Passive] on model | .Passive() | No native passive clause | [Passive] on model | [Passive] on model |
| Append-metadata filters | No | No | No | Tags, source type, stream type | Tags, source type, stream type |
| Replay lifecycle hooks | No handler hooks | No handler hooks | No hook clauses | Static Task hooks; hook services allowed | Replay notification interfaces |
| Decision reads | Admitted shapes only | Admitted shapes only | No direct .NET client-catalog registration path | Admitted without filters or constructor dependencies | Admitted without filters |
| ReadModelScenario | Yes | Yes | No direct PDL overload | Yes | Yes, including nominated plain classes |
Metadata subscription filters have no stream-ID option. Observer tags are descriptive labels, not append-tag filters. For decision reads, check admission before building the workflow: the projection must be a finite, flat event-log shape without joins or hierarchy, with a supported source key (or an admitted stream-scoped key). PDL’s save path also chooses effective sequence and generation settings; do not assume a declaration has every configuration option of a code-authored projection.
When it is the wrong fit
Section titled “When it is the wrong fit”A projection is the wrong fit when its mappings hide the state transition. If you need several branches over previous state or guarded calculations, use a reducer. Adding a fluent class does not turn the mapping engine into arbitrary per-event code. PDL likewise accepts Chronicle’s supported projection expressions, not every expression accepted by Screenplay.
A reducer is the wrong fit when you are rebuilding projection features yourself. Counts and sums
already have mapping operations. Cross-source joins, custom aggregation keys, and mutually exclusive
variant groups belong to projections. Reducers are event-source keyed. A classic reducer with filters or constructor dependencies other than ILogger<T> cannot be read as a protected decision read; a self-reducing read model is exempt from the dependency rule. See Reducers as decision reads. Choose an instance reducer over a self reducer only when its separation,
DI, or instance behavior is useful; do not hide durable state in the service instance. Replay invokes
your code again, and none of the reducer forms promises exactly-once execution or external effects.
Choose materialized or passive reads separately from authoring style. Passive by-ID projection reads select one event source, so cross-source joins and custom-key aggregations cannot generally reconstruct the materialized answer that way. Passive reads are not a synchronous append mode, and an on-demand result does not enforce uniqueness.
What it costs to switch later
Section titled “What it costs to switch later”You can keep event contracts, the read-model schema, the query surface, and the expected field values when the new form expresses the same behavior. Storage continuity takes more care:
| What to check | Why it matters |
|---|---|
| Read-model identifier | In .NET it is the full model type name. Renaming or moving the model changes that identity. |
| Observer identifier and scope | Attributes default to the model’s full name; fluent and separate reducer classes default to their own full names; self reducers default to the model’s full name; PDL uses its declaration name. Explicit IDs can preserve observer identity, but store, namespace, and sequence also scope progress. |
| Container name | The default .NET naming policy pluralizes the short model name. A namespace move can keep the container while changing identity; a type rename usually changes both. Fluent projections can override the container; reducers have no equivalent override. A rename points reads elsewhere and does not copy data. |
| Replay or rebuild | Definition and reducer-implementation changes are evaluated under the configured evolution policy. Expect replay work or a recommendation; not every source edit triggers a full rebuild. Reusing an ID across projection and reducer kinds is not a state or schema migration. |
| Compliance lineage | A projection’s per-property subject tracking and a reducer’s batch/default subject protection differ. Keep model PII annotations and verify subject ownership, release, and erasure after a family change. |
For a safe switch, record the existing IDs, scope, container, and configuration first. Preserve the identifiers you intend to keep and remove the previous builder; a self model cannot share ownership with another reducer or projection in the same catalog. When moving a fold to static methods, also replace constructor dependencies and replay interfaces deliberately.
Run the same event histories against the replacement, then exercise registration and replay against a real kernel and the intended sink. Verify stored-state compatibility, catch-up, query results, indexes, and compliance. Inspect registration outcomes and projection evolution and retirement; a running client alone does not prove every definition registered. Coordinate stopping the old writer before starting the replacement. Projection retirement retains its container, and its successor recommendations concern projections, not reducers. Plan the rebuild explicitly when changing families.
Test the behavior, then test the boundary
Section titled “Test the behavior, then test the boundary”The same ReadModelScenario can exercise all four application-code forms above, as well as a nominated plain reducer configured through per-run defaults. Give each form the same events and assert the same fields, including re-registration and missing current state.
using Cratis.Chronicle;using Cratis.Chronicle.Testing;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Specifications;
// Uses the events and four implementations above. Call VerifyAll() from a test.public static class ChoosingStyleVerification{ public static async Task VerifyAll() { await Verify<ChoosingStyleBookStatusModelBound>(m => new(m.Id, m.Title, m.Isbn, m.IsBorrowed, m.BorrowedBy)); await Verify<ChoosingStyleBookStatusFluent>(m => new(m.Id, m.Title, m.Isbn, m.IsBorrowed, m.BorrowedBy)); await Verify<ChoosingStyleBookStatusSelfReducing>(m => new(m.Id, m.Title, m.Isbn, m.IsBorrowed, m.BorrowedBy)); await Verify<ChoosingStyleBookStatusReducerModel>(m => new(m.Id, m.Title, m.Isbn, m.IsBorrowed, m.BorrowedBy)); }
static async Task Verify<T>(Func<T, ChoosingStyleBookStatus> fields) where T : class { var artifacts = new InMemoryClientArtifactsProvider { EventTypes = [typeof(ChoosingStyleBookRegistered), typeof(ChoosingStyleBookBorrowed), typeof(ChoosingStyleBookReturned)], ModelBoundProjections = [typeof(ChoosingStyleBookStatusModelBound)], Projections = [typeof(ChoosingStyleBookStatusProjection)], Reducers = [typeof(ChoosingStyleBookStatusSelfReducing), typeof(ChoosingStyleBookStatusReducer)] }; var defaults = new Defaults(artifacts); var registered = new ChoosingStyleBookRegistered("Dune", "9780441172719"); var borrowed = new ChoosingStyleBookBorrowed("Avery"); var returned = new ChoosingStyleBookReturned(); var available = new ChoosingStyleBookStatus("book-42", "Dune", "9780441172719", false, null);
var empty = new ReadModelScenario<T>(null, defaults); empty.Instance!.ShouldBeNull();
await Check([registered], available); await Check([registered, borrowed], available with { IsBorrowed = true, BorrowedBy = "Avery" }); await Check([registered, borrowed, returned], available); await Check([registered, borrowed, registered], available); await Check([borrowed], new("book-42", null, null, true, "Avery")); await Check([returned], new("book-42", null, null, false, null));
async Task Check(object[] events, ChoosingStyleBookStatus expected) { var scenario = new ReadModelScenario<T>(null, defaults); await scenario.Given.ForEventSource("book-42").Events(events); fields(scenario.Instance!).ShouldEqual(expected); } }}Kotlin does not support this workflow yet.Track self-reducing read model support: https://github.com/Cratis/Chronicle/issues/4851Java does not support this workflow yet.Track self-reducing read model support: https://github.com/Cratis/Chronicle/issues/4851This Chronicle client does not support this workflow yet.Track https://github.com/Cratis/Chronicle/issues/4851 before verifying all four read-model styles, including the self-reducing form, from Elixir.TypeScript does not support this workflow yet.Track https://github.com/Cratis/Chronicle/issues/4851 before using this API from this language.The check compares values rather than model type identity. In-process scenarios substitute storage, observer lifecycle, and read-model compliance: they do not prove transport, replay policy, encryption, erasure, sink subject ownership, or migration of existing documents. Keep the scenario as a behavioral regression check and add a real-kernel check before treating a style change as an operationally safe replacement.