Skip to content

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.

For application code, follow this order:

  1. Start with model-bound attributes when the mapping reads clearly on the model.
  2. Use fluent IProjectionFor<T> when the mapping needs structure or the model should stay attribute-free.
  3. 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.
  4. 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.

You need…PickWhy
A screen showing a book’s title and current borrowerModel-bound attributesThe events directly set or clear properties. Keep those mappings next to the shape the screen reads.
A dashboard counting loans or summing amountsA projection; attributes firstCounting, 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 booksFluent projection when the attributes become hard to scanChildren describe child identity, parent routing, updates, and removal. Attributes support children too.
A book view with a member’s details from another event sourceA projection, often fluentA 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 modelFluent projectionThe builder makes key structure and context mappings explicit without decorating the model.
A status that depends on the previous status and a ruleSelf-reducing modelBranch on current and return the next state. The rule and state live together.
A running balance that ignores a debit under a guardSelf-reducing modelRead 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 clockSeparate 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 contractSeparate IReducerFor<T> for stateful logic; fluent projection for mappingKeep behavior in the consuming application without adding transitions or mapping attributes to the contract.
An observer assembly that cannot reference ChronicleNominated plain classRegister 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 recordSelf-reducing model with [ReadModel] and static query methodsKeep 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 demandA 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 ScreenplayPDLThe definition is text owned by the tooling workflow. Check Chronicle’s supported expression subset and save settings.

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;

View C# snippet source on GitHub

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

View C# snippet source on GitHub

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

View C# snippet source on GitHub

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.

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

View C# snippet source on GitHub

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

View C# snippet source on GitHub

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

View C# snippet source on GitHub

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.

For event source book-42, all four implementations produce these field values:

Event historyTitle / ISBNBorrowed?Borrower
No eventsNo instance——
Registered(“Dune”, “9780441172719”)Dune / 9780441172719falsenot set
Registered → Borrowed(“Avery”)Dune / 9780441172719trueAvery
Registered → Borrowed → ReturnedDune / 9780441172719falsenot set
Registered → Borrowed → Registered(“Dune”, “9780441172719”)Dune / 9780441172719falsenot set
Borrowed(“Avery”) without registrationnot settrueAvery
Returned without registrationnot setfalsenot 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.

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.

CapabilityAttributesFluentPDLSelf reducerSeparate reducer
Logic locationModel metadataSeparate Define classDeclaration textStatic model methodsInstance methods on another class
Dependency injection during foldNoNo; DI can build the definitionNoNoYes
Joins across event sourcesYesYesYesNo native joinsNo native joins
Children / nested objectsMappingsMappingsBlocksYour codeYour code
Counters and sumsOperations; no dynamic dictionary-counter formOperations, including dynamic dictionary countersOperationsYour code, per sourceYour code, per source
Remove an instanceRemoval attributesRemoval buildersRemoval blocksReturn nullReturn null
Variant groupsVariantOf / EntersOn / GlobalForVariantOf / EntersOn; no GlobalFor equivalentNo native group blockNo native group wiringNo native group wiring
Passive reads[Passive] on model.Passive()No native passive clause[Passive] on model[Passive] on model
Append-metadata filtersNoNoNoTags, source type, stream typeTags, source type, stream type
Replay lifecycle hooksNo handler hooksNo handler hooksNo hook clausesStatic Task hooks; hook services allowedReplay notification interfaces
Decision readsAdmitted shapes onlyAdmitted shapes onlyNo direct .NET client-catalog registration pathAdmitted without filters or constructor dependenciesAdmitted without filters
ReadModelScenarioYesYesNo direct PDL overloadYesYes, 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.

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.

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 checkWhy it matters
Read-model identifierIn .NET it is the full model type name. Renaming or moving the model changes that identity.
Observer identifier and scopeAttributes 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 nameThe 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 rebuildDefinition 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 lineageA 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.

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

View C# snippet source on GitHub

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.