--- name: cratis-readmodel description: Step-by-step guidance for creating a Cratis Chronicle read model from scratch — defining events, choosing between projection and reducer, [ReadModel] record with static query methods, and the generated TypeScript proxy in React. Use when creating a read model, working with [EventType], [ReadModel], IProjectionFor, IReducerFor, observable queries, or deriving state from events. For adding a projection or reactor to an existing read model, use add-projection instead. --- # 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**. ```csharp // Accounts/AccountSummary/AccountSummary.cs — events live in the slice file they belong to using Cratis.Chronicle.Events; /// Emitted when a debit account is opened. [EventType] public record DebitAccountOpened(AccountName Name, OwnerId OwnerId); /// Emitted when a debit account is closed. [EventType] public record DebitAccountClosed; /// Emitted when funds are deposited. [EventType] public record FundsDeposited(Money Amount); /// Emitted when funds are withdrawn. [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. ```csharp // 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> AllAccounts( IMongoCollection collection) => await collection.Find(Builders.Filter.Empty).ToListAsync(); public static async Task GetAccount( AccountId id, IMongoCollection collection) => await collection.Find(a => a.Id == id).FirstOrDefaultAsync(); // Observable query — pushes updates in real time public static ISubject> ObserveAllAccounts( IMongoCollection collection) => collection.Observe(); } ``` **Rules:** - `[ReadModel]` attribute is **required** for proxy generation and runtime routing - Static methods must be `public static` and return the record type, a collection of it, or `ISubject` for real-time push - Do **not** return `Task>` — observable methods must return `ISubject` directly - Use `ConceptAs` wrappers for all identity fields — never raw `Guid` - 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 ```csharp // 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 { public void Define(IProjectionBuilderFor builder) => builder .From(from => from .Set(m => m.Balance).WithValue(0m)) // Name/OwnerId map by AutoMap (matching names) .From(from => from .Add(m => m.Balance).With(e => e.Amount)) .From(from => from .Subtract(m => m.Balance).With(e => e.Amount)) .From(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()`; only `.Set().To()` the ones whose names differ. - Discovered automatically — no registration needed. - `IProjectionFor` is keyed by **event source ID** by default (the `Id` passed when appending the event). - Appended `tags`, `eventSourceType`, and `eventStreamType` do not filter projections directly; use reducers or reactors alongside the projection when you need metadata-based filtering - See `references/projections.md` for joins, auto-mapping, children, composite keys ### Model-bound shorthand (preferred for simple cases) `[FromEvent]` 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]`. The model needs `[ReadModel]`, and the key is the event-source id (no `[Key]` needed when the id property is the `EventSourceId` identity). ```csharp using Cratis.Chronicle.Projections.ModelBound; [ReadModel] [FromEvent] // 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(nameof(DebitAccountOpened.OwnerName))] OwnerName Owner // only when names differ ); ``` - `[FromEvent]` goes on the **class**, not a property. Property-level mapping uses `[SetFrom]`, and only for genuine name differences — **never call `.AutoMap()`**; matching names map automatically. - Add more `[FromEvent]` attributes to fold in additional events. --- ## Step 4B — Implement a reducer Use a reducer when the state is built by accumulating values across events: ```csharp // Domain/Reducers/AccountBalanceReducer.cs using Cratis.Chronicle.Events; using Cratis.Chronicle.Reducers; public class AccountBalanceReducer : IReducerFor { 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` - `current` is `null` on the first event for a given event source - `EventContext` provides `Occurred`, `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 ```csharp [ReadModel] public record AccountSummary(AccountId Id, string Name, decimal Balance) { public static async Task> AllAccounts( IMongoCollection collection) => await collection.Find(_ => true).ToListAsync(); public static async Task GetAccount( AccountId id, IMongoCollection collection) => await collection.Find(a => a.Id == id).FirstOrDefaultAsync(); } ``` ### Observable (real-time push) queries Return `ISubject` to push updates as projection changes land: ```csharp [ReadModel] public record AccountSummary(AccountId Id, string Name, decimal Balance) { public static ISubject> ObserveAllAccounts( IMongoCollection collection) => collection.Observe(); public static ISubject ObserveAccount( AccountId id, IMongoCollection 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 ```bash dotnet build # generates TypeScript proxies ``` ```tsx import { AllAccounts } from '../api/Accounts/AllAccounts'; export const AccountList = () => { const [accounts] = AllAccounts.use(); if (accounts.isPerforming) return ; return (
    {accounts.data.map(a => (
  • {a.name} — ${a.balance}
  • ))}
); }; ``` 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 |