Creating a Cratis Read Model
A read model is derived state built from events. The path is:
[EventType] records → [ReadModel] record + static query methods → projection or reducer → TypeScript proxy → React
Step 1 — Define your events
Events are the source of truth. Define each as a record decorated with [EventType]. Name them in past tense.
// Accounts/AccountSummary/AccountSummary.cs — events live in the slice file they belong to
using Cratis.Chronicle.Events;
/// <summary>Emitted when a debit account is opened.</summary>
[EventType]
public record DebitAccountOpened(AccountName Name, OwnerId OwnerId);
/// <summary>Emitted when a debit account is closed.</summary>
[EventType]
public record DebitAccountClosed;
/// <summary>Emitted when funds are deposited.</summary>
[EventType]
public record FundsDeposited(Money Amount);
/// <summary>Emitted when funds are withdrawn.</summary>
[EventType]
public record FundsWithdrawn(Money Amount);
Good event design:
- One clear purpose per event — do not mix concerns.
- Avoid nullable properties — Chronicle's analyzer warns on them; model an optional fact as a separate event.
- Properties are concept-typed facts (never raw
Guid/string), and never carry the event-source id.
Step 2 — Define the read model record
Decorate the record with [ReadModel] and add static query methods directly on it. The proxy generator turns each static method into a TypeScript query class.
// Domain/ReadModels/AccountSummary.cs
using Cratis.Arc.Queries.ModelBound;
using MongoDB.Driver;
[ReadModel]
public record AccountSummary(AccountId Id, string Name, OwnerId OwnerId, decimal Balance, bool IsClosed)
{
// Snapshot query — returns current data once
public static async Task<IEnumerable<AccountSummary>> AllAccounts(
IMongoCollection<AccountSummary> collection)
=> await collection.Find(Builders<AccountSummary>.Filter.Empty).ToListAsync();
public static async Task<AccountSummary?> GetAccount(
AccountId id,
IMongoCollection<AccountSummary> collection)
=> await collection.Find(a => a.Id == id).FirstOrDefaultAsync();
// Observable query — pushes updates in real time
public static ISubject<IEnumerable<AccountSummary>> ObserveAllAccounts(
IMongoCollection<AccountSummary> collection)
=> collection.Observe();
}
Rules:
[ReadModel]attribute is required for proxy generation and runtime routing- Static methods must be
public staticand return the record type, a collection of it, orISubject<T>for real-time push - Do not return
Task<ISubject<T>>— observable methods must returnISubject<T>directly - Use
ConceptAs<T>wrappers for all identity fields — never rawGuid - One read model per use case — do not reuse them
Step 3 — Choose: projection or reducer?
| Projection | Reducer | |
|---|---|---|
| Best for | Shaped read models with mapping logic, joins, children | Running aggregates: balances, counts, sums |
| How it works | Declarative mapping: each event updates specific fields | Receives events one by one and returns the new full state |
| When to pick | The read model shape comes mostly from mapping event fields | The state is a function of accumulating multiple events |
For the AccountSummary above: use a projection for name/owner fields and a reducer for balance (a running total). In practice, reducers cover both when the aggregate combines both concerns.
Step 4A — Implement a projection
// In the slice file — fluent projection (drop to this only when model-bound can't express the shape)
using Cratis.Chronicle.Projections;
public class AccountSummaryProjection : IProjectionFor<AccountSummary>
{
public void Define(IProjectionBuilderFor<AccountSummary> builder) => builder
.From<DebitAccountOpened>(from => from
.Set(m => m.Balance).WithValue(0m)) // Name/OwnerId map by AutoMap (matching names)
.From<FundsDeposited>(from => from
.Add(m => m.Balance).With(e => e.Amount))
.From<FundsWithdrawn>(from => from
.Subtract(m => m.Balance).With(e => e.Amount))
.From<DebitAccountClosed>(from => from
.Set(m => m.IsClosed).WithValue(true));
}
- AutoMap is on by default — never call
.AutoMap(). Matching property names (e.g.Name,OwnerId) map automatically from.From<DebitAccountOpened>(); only.Set().To()the ones whose names differ. - Discovered automatically — no registration needed.
IProjectionFor<T>is keyed by event source ID by default (theIdpassed when appending the event).- Appended
tags,eventSourceType, andeventStreamTypedo not filter projections directly; use reducers or reactors alongside the projection when you need metadata-based filtering - See
references/projections.mdfor joins, auto-mapping, children, composite keys
Model-bound shorthand (preferred for simple cases)
[FromEvent<T>] is a class-level attribute declaring which event populates the model; property mapping is implicit via AutoMap (matching names) or explicit per-property with [SetFrom<T>]. The model needs [ReadModel], and the key is the event-source id (no [Key] needed when the id property is the EventSourceId<T> identity).
using Cratis.Chronicle.Projections.ModelBound;
[ReadModel]
[FromEvent<DebitAccountOpened>] // class-level: this event populates the model
public record AccountInfo(
AccountId Id, // event-source id — no [Key] needed
AccountName Name, // AutoMap wires DebitAccountOpened.Name (matching name)
[SetFrom<DebitAccountOpened>(nameof(DebitAccountOpened.OwnerName))] OwnerName Owner // only when names differ
);
[FromEvent<T>]goes on the class, not a property. Property-level mapping uses[SetFrom<T>], and only for genuine name differences — never call.AutoMap(); matching names map automatically.- Add more
[FromEvent<T>]attributes to fold in additional events.
Step 4B — Implement a reducer
Use a reducer when the state is built by accumulating values across events:
// Domain/Reducers/AccountBalanceReducer.cs
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Reducers;
public class AccountBalanceReducer : IReducerFor<AccountBalance>
{
public AccountBalance Opened(DebitAccountOpened @event, AccountBalance? current, EventContext context)
=> new(0m, context.Occurred);
public AccountBalance Deposited(FundsDeposited @event, AccountBalance? current, EventContext context)
=> (current ?? new(0m, context.Occurred)) with { Balance = (current?.Balance ?? 0m) + @event.Amount };
public AccountBalance Withdrawn(FundsWithdrawn @event, AccountBalance? current, EventContext context)
=> (current ?? new(0m, context.Occurred)) with { Balance = (current?.Balance ?? 0m) - @event.Amount };
}
public record AccountBalance(decimal Balance, DateTimeOffset LastUpdated);
- Return the complete new state — do not mutate
current currentisnullon the first event for a given event sourceEventContextprovidesOccurred,EventSourceId,SequenceNumber,CorrelationId- Discovered automatically — no registration needed
- Add
[FilterEventsByTag],[EventSourceType], and[EventStreamType]when the reducer should only observe events appended with matching metadata
Step 5 — Expose read model queries
Query methods live directly on the [ReadModel] record as static methods (see Step 2). You do not need a separate controller or IReadModels injection.
The method name becomes the TypeScript proxy class name — use descriptive names like AllAccounts, GetAccount, ObserveAllAccounts.
Snapshot (one-time) queries
[ReadModel]
public record AccountSummary(AccountId Id, string Name, decimal Balance)
{
public static async Task<IEnumerable<AccountSummary>> AllAccounts(
IMongoCollection<AccountSummary> collection)
=> await collection.Find(_ => true).ToListAsync();
public static async Task<AccountSummary?> GetAccount(
AccountId id,
IMongoCollection<AccountSummary> collection)
=> await collection.Find(a => a.Id == id).FirstOrDefaultAsync();
}
Observable (real-time push) queries
Return ISubject<T> to push updates as projection changes land:
[ReadModel]
public record AccountSummary(AccountId Id, string Name, decimal Balance)
{
public static ISubject<IEnumerable<AccountSummary>> ObserveAllAccounts(
IMongoCollection<AccountSummary> collection)
=> collection.Observe();
public static ISubject<AccountSummary> ObserveAccount(
AccountId id,
IMongoCollection<AccountSummary> collection)
=> collection.Observe(a => a.Id == id);
}
When the frontend uses an observable query, the query proxy type changes from QueryFor to ObservableQueryFor. The same query prop accepts a standard or observable query — there is no separate observableQuery prop; DataPage auto-detects it and subscribes to live updates.
Step 6 — Build and use in React
dotnet build # generates TypeScript proxies
import { AllAccounts } from '../api/Accounts/AllAccounts';
export const AccountList = () => {
const [accounts] = AllAccounts.use();
if (accounts.isPerforming) return <Spinner />;
return (
<ul>
{accounts.data.map(a => (
<li key={a.id}>{a.name} — ${a.balance}</li>
))}
</ul>
);
};
For building full pages with filtering, sorting, and command actions — see the cratis-react-page skill.
Reference files
| File | What's in it |
|---|---|
references/projections.md |
Full builder API: Set, Add, Join, Children, AutoMap, composite keys |
references/reducers.md |
Reducer signatures, async, passive, snapshot behavior |
references/events.md |
[EventType], appending, AppendResult, tags, constraints |
references/queries.md |
Query result shape, observable queries, paging |