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.
Read models are derived and disposable
Section titled “Read models are derived and disposable”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.
Shape it for the query, not the domain
Section titled “Shape it for the query, not the domain”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.
Specialize over reuse
Section titled “Specialize over reuse”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 eventsReach for the declarative path first
Section titled “Reach for the declarative path first”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.
The constructor may not run
Section titled “The constructor may not run”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 want | Where it belongs |
|---|---|
A collection that is never null | The declaration. A non-nullable collection property materializes as empty — see Empty child collections. |
| A meaningful default before any event assigns the property | The projection — SetValue or initial values — so the default is part of the projected state every reader sees. |
| A rule that rejects bad data | The 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. |
Design for eventual consistency
Section titled “Design for eventual consistency”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);}import io.cratis.chronicle.IEventStore
data class DesigningReadModelsCustomerDetail(val id: String, val name: String)
class DesigningReadModelsCustomerDetailService(private val store: IEventStore) { suspend fun getDetail(customerId: String): DesigningReadModelsCustomerDetail? = store.readModels.getInstanceByKey(DesigningReadModelsCustomerDetail::class, customerId)}import io.cratis.chronicle.IEventStore;import io.cratis.chronicle.java.BlockingEventStore;
record DesigningReadModelsCustomerDetail(String id, String name) {}
class DesigningReadModelsCustomerDetailService { private final BlockingEventStore store;
DesigningReadModelsCustomerDetailService(IEventStore store) { this.store = new BlockingEventStore(store); }
DesigningReadModelsCustomerDetail getDetail(String customerId) { return store.getReadModels() .getInstanceByKey(DesigningReadModelsCustomerDetail.class, customerId); }}defmodule MyApp.ReadModels.DesigningReadModelsCustomerDetail do defstruct [:id, :name]end
defmodule MyApp.DesigningReadModelsCustomerDetailService do alias MyApp.ReadModels.DesigningReadModelsCustomerDetail
def get_detail(customer_id) do Chronicle.read_model(DesigningReadModelsCustomerDetail, customer_id) endendimport { IEventStore } from '@cratis/chronicle';
class DesigningReadModelsCustomerDetail { constructor( readonly id: string, readonly name: string ) {}}
class DesigningReadModelsCustomerDetailService { constructor(private readonly store: IEventStore) {}
getDetail(customerId: string): Promise<DesigningReadModelsCustomerDetail> { return this.store.readModels.getInstanceById(DesigningReadModelsCustomerDetail, customerId); }}Deep dive: consistency covers when each model is the right call.
Query it
Section titled “Query it”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); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.ReadModels.DesigningReadModelsCustomerListItem do defstruct [:id, :name]end
defmodule MyApp.DesigningReadModelsCustomerListService do alias MyApp.ReadModels.DesigningReadModelsCustomerListItem
def get_all_strongly_consistent do # Strongly consistent — Chronicle replays the read model's events on demand Chronicle.all(DesigningReadModelsCustomerListItem) end
def get_page_eventually_consistent do # Eventually consistent — a page of materialized instances straight from storage Chronicle.ReadModels.query(DesigningReadModelsCustomerListItem, page: 1, page_size: 20) endendimport { IEventStore } from '@cratis/chronicle';
class DesigningReadModelsCustomerListItem { constructor( readonly id: string, readonly name: string ) {}}
class DesigningReadModelsCustomerListService { constructor(private readonly store: IEventStore) {}
// Strongly consistent — Chronicle replays the read model's events on demand getAllStronglyConsistent(): Promise<DesigningReadModelsCustomerListItem[]> { return this.store.readModels.getInstances(DesigningReadModelsCustomerListItem); }
// Eventually consistent — a page of materialized instances straight from storage getPageEventuallyConsistent(): Promise<DesigningReadModelsCustomerListItem[]> { return this.store.readModels.materialized.getInstances(DesigningReadModelsCustomerListItem, 0, 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 yourDbContext— 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.
Where this leads
Section titled “Where this leads”- Read Models — retrieval, snapshots, and consistency in depth.
- Projections, reducers, and reactors — how the read side is built.
- Modeling events well — the source of truth your read models derive from.