Skip to content

Designing read models

If modeling events well is about capturing truth, designing read models is about presenting it. The events are your single source of truth; a read model is just one view of them, built for one job. That reframing changes how you design.

The guiding principle: specialize, don’t share. Build a focused read model per use case rather than one model stretched across conflicting screens.

A read model holds no truth of its own — it’s computed from events by a projection. That has a liberating consequence: you can create, change, and throw away read models freely. Got the shape wrong? Change the projection and rebuild. There’s no precious state to migrate, because the events are the state.

A normalized, one-size-fits-all model is a relational-database habit. Here, design each read model around how it will be read:

  • A list screen wants a flat, denormalized row per item.
  • A detail screen wants one rich document with its children embedded.
  • A dashboard wants pre-aggregated counts.

These can all be separate read models built from the same events. Denormalize without guilt — there’s no update anomaly to fear, because you never update a read model by hand; the projection rebuilds it from the facts.

It’s tempting to make one Customer read model serve every screen. Resist it. The moment two screens want conflicting shapes, a shared model becomes a compromise that serves neither well and breaks one when you change it for the other. A dedicated model per use case is easier to reason about, faster to query, and safe to change in isolation.

❌ One CustomerReadModel feeding the list, the detail page, and the billing report
✅ CustomerListItem · CustomerDetail · CustomerBillingSummary — each from the same events

You rarely write code that updates a read model — you declare how events map onto it and let Chronicle do the folding. Start with model-bound projections, where attributes on the read model record describe the mapping. When the mapping needs logic the attributes can’t express cleanly, step up to the fluent declarative projection builder (IProjectionFor<T>), and only reach for a reducer when the model is genuinely easier to express as code folding over previous state.

Sooner or later you’ll want to put a small piece of logic in the read model record itself — a normalizer that turns a null collection into an empty one, a computed default, a guard that rejects a value that should never arrive:

public record LineItem(string ProductName, int Quantity, decimal Price);
// Don't rely on this. It may never execute.
public record OrderSummary(OrderId Id, IEnumerable<LineItem> Lines)
{
public IEnumerable<LineItem> Lines { get; init; } = Lines ?? [];
}

Whether that line runs is not yours to decide. A read model instance is materialized by a deserializer, and a document deserializer is free to build the record without running either a constructor or the initializer. The MongoDB driver does exactly that, and it is not conditional: for a record with a primary constructor it builds no creator map at all, so it creates the instance uninitialized and assigns the members by reflection. That happens on every document — a complete one just as much as one missing a field. Read a read model through IMongoCollection<T> and your normalizer never runs, ever.

That makes the blast radius wider than a null collection. Any logic you put in the record body — a default, a clamp, a trim, a validation — is dead on that path, silently.

Chronicle’s own reader does construct the record, which makes this worse rather than better: the same line can work when you read through IReadModels and quietly fail when something reads that document with IMongoCollection<T>. And even where the constructor runs, a property declared in the record body gets its stored value assigned after construction, on top of whatever its initializer produced.

So a guard written in a read model record is not a guard. Put the intent somewhere that always runs:

You wantWhere it belongs
A collection that is never nullThe declaration. A non-nullable collection property materializes as empty — see Empty child collections.
A meaningful default before any event assigns the propertyThe projection — SetValue or initial values — so the default is part of the projected state every reader sees.
A rule that rejects bad dataThe write side, before the event is appended. A read model is derived from facts that already happened; refusing them here is too late to help.

A materialized read model updates after the event is appended, so what’s stored is eventually consistent. Design with that in mind: don’t read a stored model back inside a command or reactor to make a decision (use a constraint for invariants instead), and prefer observable queries so the UI reflects changes the moment the projection catches up.

Eventual is the default, though — not a law. The IReadModels API can also compute a read model on demand by replaying its events, giving you a strongly consistent read that includes an event you appended a millisecond ago:

using Cratis.Chronicle;
using Cratis.Chronicle.ReadModels;
public record DesigningReadModelsCustomerDetail(Guid Id, string Name);
public class DesigningReadModelsCustomerDetailService(IEventStore eventStore)
{
public Task<DesigningReadModelsCustomerDetail> GetDetail(Guid customerId) =>
eventStore.ReadModels.GetInstanceById<DesigningReadModelsCustomerDetail>(customerId);
}

Deep dive: consistency covers when each model is the right call.

When it’s time to read, Chronicle gives you a ladder — trade consistency for cost as you descend:

using Cratis.Chronicle;
public record DesigningReadModelsCustomerListItem(Guid Id, string Name);
public class DesigningReadModelsCustomerListService(IEventStore eventStore)
{
public async Task<IEnumerable<DesigningReadModelsCustomerListItem>> GetAllStronglyConsistent()
{
// Strongly consistent — Chronicle replays the read model's events on demand
return await eventStore.ReadModels.GetInstances<DesigningReadModelsCustomerListItem>();
}
public async Task<IEnumerable<DesigningReadModelsCustomerListItem>> GetPageEventuallyConsistent()
{
// Eventually consistent — a page of materialized instances straight from storage
return await eventStore.ReadModels.Materialized.GetInstances<DesigningReadModelsCustomerListItem>(skip: 0, take: 20);
}
}
  • GetInstances<T>() rebuilds every instance from the event log at call time, so the result reflects everything appended so far. You can cap the work with an event count (GetInstances<T>(eventCount)), but a capped replay may return incomplete results. Replay cost grows with history — great for reporting and short histories, not necessarily your production list view.
  • Materialized.GetInstances<T>(skip, take) reads what projections have already stored, with paging — cheap and fast, a moment behind. See Materialized read models.
  • Need filtering, sorting, or aggregation? Go to the sink’s native query tools — IMongoCollection<T> or your DbContext — because a materialized read model is just data in a database, shaped for exactly this.

Kotlin and Java currently only support the strongly-consistent single-instance lookup shown above — there’s no way to get all instances or page through materialized storage from those two clients yet.

Keep the read side ignorant of the write side

Section titled “Keep the read side ignorant of the write side”

A projection only knows about events. It must not trigger commands, call external systems, or produce side effects — that’s a reactor’s job. Keeping projections pure is what makes them safe to replay at any time.