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.
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 kotlin.jvm.JvmClassMappingKt;import kotlinx.coroutines.BuildersKt;import kotlin.coroutines.EmptyCoroutineContext;import kotlin.coroutines.Continuation;
record DesigningReadModelsCustomerDetail(String id, String name) {}
class DesigningReadModelsCustomerDetailService { private final IEventStore store;
DesigningReadModelsCustomerDetailService(IEventStore store) { this.store = store; }
DesigningReadModelsCustomerDetail getDetail(String customerId) throws InterruptedException { return (DesigningReadModelsCustomerDetail) BuildersKt.runBlocking( EmptyCoroutineContext.INSTANCE, (scope, continuation) -> { @SuppressWarnings("unchecked") var readContinuation = (Continuation<? super DesigningReadModelsCustomerDetail>) continuation; return store.getReadModels().getInstanceByKey( JvmClassMappingKt.getKotlinClass(DesigningReadModelsCustomerDetail.class), customerId, readContinuation); }); }}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.