From script to stage · Part 03: From event to read model
Cratis: from script to stage, and the long run · Part 03 of 26
[FromEvent<AuthorRegistered>] on a record is a complete projection. Chronicle maps the event’s Name onto the read model’s Name because the names match, keys each instance by the author, runs the projection in its kernel and writes the result to a database that a query can read. There’s no handler and no mapping code to write.
When the author list is wrong, the cause is somewhere in what that attribute sets up.
The author list, end to end
Section titled “The author list, end to end”The read model is one record:
[ReadModel][FromEvent<AuthorRegistered>]public record Author(AuthorId Id, AuthorName Name){ public static ISubject<IEnumerable<Author>> AllAuthors(IMongoCollection<Author> collection) => collection.Observe();}@io.cratis.arc.artifacts.ReadModel@io.cratis.chronicle.readModels.ReadModel@FromEvent(AuthorRegistered::class)data class Author( @FromEventSourceId val id: AuthorId = AuthorId(UUID(0, 0)), val name: AuthorName = AuthorName("")) { companion object { @JvmStatic fun allAuthors(@FromServices queries: MongoObservableQuery): Flow<List<Author>> = queries.observe<Author>() }}@io.cratis.arc.artifacts.ReadModel@io.cratis.chronicle.readModels.ReadModel@FromEvent(eventType = AuthorRegistered.class)public class Author { @FromEventSourceId public AuthorId id = new AuthorId(new UUID(0, 0));
public AuthorName name = new AuthorName("");
public static Flow.Publisher<List<Author>> allAuthors(@FromServices MongoObservableQuery queries) { return queries.observePublisher(Author.class); }}Arc for TypeScript reads the projected read model through Chronicle’s read models, ChronicleReadModels, not from a MongoDB collection.
@readModel()@fromEvent(AuthorRegistered)export class Author { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName;
@query({ observable: true }, service(ChronicleReadModels)) static allAuthors(models: ChronicleReadModels): Observable<Author[]> { return models.observeAll(Author, author => author.id.toString()); }}Arc for Elixir doesn’t exist; use the Chronicle Elixir client directly, where the read model declares the projection:
defmodule MyApp.ReadModels.Author do use Chronicle.ReadModels.ReadModel
alias MyApp.Events.AuthorRegistered
defstruct id: %MyApp.AuthorId{}, name: %MyApp.AuthorName{}
from AuthorRegistered, set: [id: :event_source_id, name: :name]endTwo products meet in those few lines. [FromEvent<AuthorRegistered>] belongs to Chronicle and declares the projection. Properties with the same name as the event’s properties are mapped automatically, and the instance is keyed by the event source, so every author gets an instance of their own. [ReadModel] and the static AllAuthors method belong to Arc. [ReadModel] marks a type that Arc serves through queries, and it works without Chronicle. AllAuthors is an observable query that Arc routes, and its generated TypeScript gives the React code an AllAuthors.use() hook.
Arc’s walkthrough on adding event sourcing keeps this query unchanged from the chapter before Chronicle was added, so the proxy and the hook don’t change either. The documents now come from a projection, and Arc reads the collection it wrote. That only works when Arc is configured with Cratis.Arc.MongoDB and agrees with Chronicle’s sink on the database, the collection names, the key mapping, serialization and the tenant mapping. Running both in one application host doesn’t line those up for you. The .NET walkthrough builds a slice like this end to end with a runnable project.

Seven hops from an append to a live list. The append returns after the first.
One definition, written three ways
Section titled “One definition, written three ways”Chronicle accepts a projection in three forms, and all of them end up as the same thing. A model-bound projection is read from attributes on the read model by reflection. A declarative projection is built in code through a builder. A PDL projection is text that the server compiles. Each produces the same projection definition. The kernel stores it, runs it in its projection engine and pushes the changes to a sink. The projection architecture walks through the pipeline.
Model-bound is the default for most projections, because the read model then is the projection and there’s one file to read. The fluent builder takes over when attributes can’t express the logic. PDL suits tooling and previews, where a projection is written and tried out without touching application code.

Three ways to write a projection, one definition in the kernel.
Model-bound: the read model is the projection
Section titled “Model-bound: the read model is the projection”The model-bound form puts the mapping on the record. Names that match are mapped, and [SetFrom<T>] maps one that doesn’t:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Projections.ModelBound;
[EventType]public record AccountOpened(AccountName Name, Amount InitialBalance);
[FromEvent<AccountOpened>]public record AccountInfo( AccountId Id, AccountName Name, // AutoMap: same name as event property [SetFrom<AccountOpened>(nameof(AccountOpened.InitialBalance))] Amount Balance);import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.projections.FromEventimport io.cratis.chronicle.projections.FromEventSourceIdimport io.cratis.chronicle.projections.SetFromimport io.cratis.chronicle.readModels.ReadModelimport java.math.BigDecimalimport java.util.UUID
@EventTypedata class AccountOpened( val name: AccountName, val initialBalance: Amount)
@ReadModel@FromEvent(AccountOpened::class)data class AccountInfo( @FromEventSourceId val id: AccountId = AccountId(UUID(0, 0)),
val name: AccountName = AccountName(""), // AutoMap: same name as event property
@SetFrom("initialBalance") val balance: Amount = Amount(BigDecimal.ZERO))import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.projections.FromEvent;import io.cratis.chronicle.projections.FromEventSourceId;import io.cratis.chronicle.projections.SetFrom;import io.cratis.chronicle.readModels.ReadModel;
import java.math.BigDecimal;import java.util.UUID;
@EventTyperecord AccountOpened(AccountName name, Amount initialBalance) {}
@ReadModel@FromEvent(eventType = AccountOpened.class)class AccountInfo { @FromEventSourceId public AccountId id = new AccountId(new UUID(0, 0));
public AccountName name = new AccountName(""); // AutoMap: same name as event property
@SetFrom(propertyPath = "initialBalance") public Amount balance = new Amount(BigDecimal.ZERO);}import { eventType, fromEvent, Guid, setFrom } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()export class AccountOpened { @field(AccountName) name = new AccountName(''); @field(Amount) initialBalance = new Amount(0);}
@fromEvent(AccountOpened)export class AccountInfo { @field(AccountId) id = new AccountId(Guid.empty); @field(AccountName) name = new AccountName(''); // AutoMap: same name as event property
@setFrom(AccountOpened, 'initialBalance') @field(Amount) balance = new Amount(0);}defmodule MyApp.Events.AccountOpened do use Chronicle.Events.EventType, id: "account-opened"
defstruct name: %MyApp.AccountName{}, initial_balance: nilend
defmodule MyApp.ReadModels.AccountInfo do use Chronicle.ReadModels.ReadModel
alias MyApp.Events.AccountOpened
defstruct id: %MyApp.AccountId{}, name: %MyApp.AccountName{}, balance: 0
from AccountOpened, set: [id: :event_source_id, name: :name, balance: :initial_balance]endA read model with a collection inside it uses children. Each child has a key, events add or update the child with that key, and another event removes it:
public record OrderLine( [Key] LineItemId Id, [SetFrom<LineItemAdded>(nameof(LineItemAdded.ProductName))] ProductName Product, [SetFrom<LineItemAdded>(nameof(LineItemAdded.InitialQuantity))] [SetFrom<QuantityAdjusted>(nameof(QuantityAdjusted.NewQuantity))] Quantity Quantity);
public record Order( OrderId Id, [SetFrom<OrderCreated>(nameof(OrderCreated.CustomerName))] CustomerName Customer, [ChildrenFrom<LineItemAdded>(key: nameof(LineItemAdded.ItemId))] [RemovedWith<LineItemRemoved>(key: nameof(LineItemRemoved.ItemId))] IEnumerable<OrderLine> Lines);data class OrderLine( val id: LineItemId = LineItemId(UUID(0, 0)),
@SetFrom("productName", LineItemAdded::class) val product: ProductName = ProductName(""),
@SetFrom("initialQuantity", LineItemAdded::class) @SetFrom("newQuantity", QuantityAdjusted::class) val quantity: Quantity = Quantity(0))
@ReadModel@FromEvent(OrderCreated::class)data class Order( @FromEventSourceId val id: OrderId = OrderId(UUID(0, 0)),
@SetFrom("customerName", OrderCreated::class) val customer: CustomerName = CustomerName(""),
@ChildrenFrom(LineItemAdded::class, key = "itemId", identifiedBy = "id") @ChildrenFrom(QuantityAdjusted::class, key = "itemId", identifiedBy = "id") @RemovedWith(LineItemRemoved::class, key = "itemId") val lines: List<OrderLine> = emptyList())class OrderLine { public LineItemId id = new LineItemId(new UUID(0, 0));
@SetFrom(propertyPath = "productName", eventType = LineItemAdded.class) public ProductName product = new ProductName("");
@SetFrom(propertyPath = "initialQuantity", eventType = LineItemAdded.class) @SetFrom(propertyPath = "newQuantity", eventType = QuantityAdjusted.class) public Quantity quantity = new Quantity(0);}
@ReadModel@FromEvent(eventType = OrderCreated.class)class Order { @FromEventSourceId public OrderId id = new OrderId(new UUID(0, 0));
@SetFrom(propertyPath = "customerName", eventType = OrderCreated.class) public CustomerName customer = new CustomerName("");
@ChildrenFrom(eventType = LineItemAdded.class, key = "itemId", identifiedBy = "id") @ChildrenFrom(eventType = QuantityAdjusted.class, key = "itemId", identifiedBy = "id") @RemovedWith(eventType = LineItemRemoved.class, key = "itemId") public List<OrderLine> lines = Collections.emptyList();}export class OrderLine { @field(LineItemId) id = new LineItemId(Guid.empty);
@setFrom(LineItemAdded, 'productName') @field(ProductName) product = new ProductName('');
@setFrom(LineItemAdded, 'initialQuantity') @setFrom(QuantityAdjusted, 'newQuantity') @field(Quantity) quantity = new Quantity(0);}
export class Order { @field(OrderId) id = new OrderId(Guid.empty);
@setFrom(OrderCreated, 'customerName') @field(CustomerName) customer = new CustomerName('');
@childrenFrom(LineItemAdded, 'itemId') @removedWith(LineItemRemoved, 'itemId') @field(Array, { genericArguments: [OrderLine] }) lines: OrderLine[] = [];}Not available in the Elixir client.
Quantity listens to two events, so the line’s quantity is set when it’s added and replaced whenever it’s adjusted. The same attribute family covers joins with [Join<T>] and removal of the whole read model with [RemovedWith<T>] on the type.
Fluent: when attributes run out
Section titled “Fluent: when attributes run out”A declarative projection implements IProjectionFor<T> and describes the same things through a builder. Children with keys and removal look like this:
using Cratis.Chronicle.Projections;
public class GroupProjection : IProjectionFor<Group>{ public void Define(IProjectionBuilderFor<Group> builder) => builder .From<GroupCreated>() .Children(m => m.Members, children => children .IdentifiedBy(m => m.UserId) .From<UserAddedToGroup>(b => b .UsingKey(e => e.UserId)) .From<UserRoleChanged>(b => b .UsingKey(e => e.UserId)) .RemovedWith<UserRemovedFromGroup>(b => b .UsingKey(e => e.UserId)));}The fluent builder in the Kotlin client has no child removal, so this projection leaves out UserRemovedFromGroup. To remove a child, put @RemovedWith on the @ChildrenFrom property, as in the model-bound example above.
import io.cratis.chronicle.projections.IProjectionBuilderForimport io.cratis.chronicle.projections.IProjectionFor
class GroupProjection : IProjectionFor<Group> { override fun define(builder: IProjectionBuilderFor<Group>) { builder .from(GroupCreated::class) .children(Group::members, GroupMember::class) { children -> children .identifiedBy("userId") .from(UserAddedToGroup::class) { it.usingKey("userId") } .from(UserRoleChanged::class) { it.usingKey("userId") } } }}The fluent builder in the Java client has no child removal, so this projection leaves out UserRemovedFromGroup. To remove a child, put @RemovedWith on the @ChildrenFrom property, as in the model-bound example above.
import io.cratis.chronicle.projections.IProjectionBuilderFor;import io.cratis.chronicle.projections.IProjectionFor;
class GroupProjection implements IProjectionFor<Group> { @Override public void define(IProjectionBuilderFor<Group> builder) { builder .from(GroupCreated.class) .children("members", GroupMember.class, children -> { children .identifiedBy("userId") .from(UserAddedToGroup.class, fb -> { fb.usingKey("userId"); }) .from(UserRoleChanged.class, fb -> { fb.usingKey("userId"); }); }); }}import { IProjectionBuilderFor, IProjectionFor, projection } from '@cratis/chronicle';
@projection()class GroupProjection implements IProjectionFor<Group> { define(builder: IProjectionBuilderFor<Group>): void { builder .from(GroupCreated) .children<GroupMember>(m => m.members, children => children .identifiedBy(m => m.userId) .from(UserAddedToGroup, b => b .usingKey(e => e.userId)) .from(UserRoleChanged, b => b .usingKey(e => e.userId)) .removedWith(UserRemovedFromGroup, b => b .usingKey(e => e.userId))); }}Not available in the Elixir client.
A join brings in events from another event source, with .Join<GroupCreated>(j => j.On(m => m.GroupId)) picking up the group’s own events for every read model that points at it. FromEvery applies a mapping for every event the projection handles, and .FromEvery(_ => _.Set(m => m.LastUpdated).ToEventContextProperty(c => c.Occurred)) stamps a last-updated time from the event’s context. A projection joins events. It can’t join another read model, and a read model that needs data from two sources gets it from both sources’ events.
PDL: the projection as text
Section titled “PDL: the projection as text”PDL, the Projection Declaration Language, is Screenplay’s language, and Chronicle’s server compiles and runs it. A PDL projection with a join and children reads like this:
projection InvoiceDetails => InvoiceDetailsReadModel from InvoiceRegistered customerId = customerId status = "draft" registeredAt = $eventContext.occurred from InvoicePaid status = "paid" join customer on customerId with CustomerRegistered customerName = name children lineItems identified by lineNumber from InvoiceLineItemAdded key lineNumber quantity = quantity remove with InvoiceLineItemRemoved key lineNumberTwo details in that text do less than they seem to. A key on the projection line is parsed but doesn’t route any events. A from without a key uses the event source, here the invoice, and one that needs another key states it, as the line items do. In join customer on customerId, the name customer is only a label and is discarded when the definition is built.
Because the server takes text and returns a definition, a PDL projection can be previewed before it’s saved. The Workbench has an editor for it, and the .NET client has IProjections.Query() for an ad-hoc, bounded query that replays from the start. The ad-hoc query is .NET only and doesn’t replace a registered projection.
Keys decide what an instance is
Section titled “Keys decide what an instance is”A read model instance is keyed by the event source unless you say otherwise. UsingKey(e => ...) in the builder, [Key] on a property and key in PDL all pick a different key from the event. A composite key combines several values, through UsingCompositeKey<TKey> with a Set(k => k.CustomerId).To(e => e.CustomerId) for each part. A constant key such as key "global" in PDL puts every event into one instance, which is how a counter across everything works. Event metadata can be part of a composite key, so the time an event occurred can bucket a histogram.
The operations inside a projection are a short list: set, add, subtract, increment, decrement, count and clear. When the logic needs more than that, it’s time for a reducer.
When attributes can’t say it: reducers
Section titled “When attributes can’t say it: reducers”A reducer implements IReducerFor<TModel> and has one method per event, such as OnBookBorrowed(BookBorrowed @event, Model? current, EventContext context) => current! with { IsBorrowed = true }. It takes the event, the current state and the event’s context, and returns the next state. current has to be nullable, because the first event for an instance arrives before there’s any state.
A reducer is your code, so it runs in your application. The kernel sends it events over a stream, and whatever it returns, Chronicle stores. It rebuilds the way a projection does. Because it’s plain code it’s also the easiest part of a read model to test, by calling the method with an event and a state and comparing the result.
Reducers and projections build state. A reactor is the third kind of observer, and it acts on events, for example by sending an email or appending a follow-up event. Reacting to facts is about reactors.
Reading a read model, and how fresh it is
Section titled “Reading a read model, and how fresh it is”A read model is eventually consistent by default. It’s materialized in the sink and catches up after the append. There are two other choices. A passive read model is computed from its events when it’s read, and a read-after-write append calls WaitForCompletion() on the result in .NET, which adds latency for that caller only. There’s no synchronous projection mode. The name “immediate projection” refers to the kernel’s on-demand component, and it doesn’t make a projection run inside the append.
Passive read models come with a catch. They replay only the events of one event source, so a projection with joins or custom keys has to stay materialized.
Chronicle’s own read API is keyed. It fetches by id, takes snapshots, watches, reads a whole collection or pages through a materialized one. There’s no predicate query and no IQueryable, by design. For a search across read models, query the sink with its own driver, which is what Arc’s IMongoCollection<Author> does in the author list.
An Arc command can also take a Chronicle read model as a parameter, resolved by the command’s event source id. It resolves only when a projection or a reducer backs it, since [ReadModel] alone doesn’t make it injectable, and a child type isn’t a read model of its own. What arrives is a snapshot for that command. It isn’t a lock, and a materialized read model may lag behind the log. A read can’t enforce a rule like unique author names. That’s a constraint’s job, in Rules that hold when you write.
Where read models are stored: sinks
Section titled “Where read models are stored: sinks”A projection writes to a sink. The MongoDB sink is the default, with one document per instance, in a collection named after the read model type in camelCase. The SQL sink creates a table per read model, with typed columns for scalar values and a JSON column for collections and nested objects, and an update touches only the columns that changed. The SQL sink doesn’t create the indexes declared with [Index]. Only the MongoDB sink does.
The sink type is chosen globally with DefaultSinkTypeId, and it can’t be set per read model from client configuration. The client hands read models back as JSON deserialized with System.Text.Json, so a MongoDB convention pack registered in your application doesn’t affect what Chronicle returns.
Watching for changes
Section titled “Watching for changes”Two separate mechanisms report that a read model changed.
Chronicle’s ReadModels.Watch<T>() is a stream of changesets. eventStore.ReadModels.Watch<Order>().Subscribe(changeset => ...) receives the namespace, the key, the current read model and whether it was removed. It carries the current state and not the previous one, and any filtering happens on the client. Not every Chronicle client exposes the watch API yet.
Arc’s observable queries are what a UI uses, and collection.Observe() in AllAuthors isn’t a Chronicle feature at all. It watches the MongoDB collection through change streams, which need a replica set, and it sees the projection’s output only because the sink wrote the documents Arc is watching. The hop from event to browser is a sink write, a MongoDB change stream and an Arc subscription, and the generated React client receives the current result and then later updates over WebSocket or Server-Sent Events. A reconnect creates a new subscription and doesn’t replay the event log. When [RemovedWith<T>] deletes a document, an ObserveSingle subscriber sees null. Arc’s Observe() is built on MongoDB change streams, so a live list over the SQL sink isn’t part of that path. Live UIs picks up from the query onwards.
When the definition changes
Section titled “When the definition changes”Your application registers every projection each time it connects, as a full set. The kernel compares the set with what it has and acts only where something changed. Registration retries with exponential backoff and doesn’t wait for catch-up. If one definition is rejected, it fails on its own, the rest register, and the client is told which one failed.
A changed definition is stored, pushed to the projection engine on every server, and any cached pipeline for it is thrown away. Then the kernel classifies the change. When it only adds event types that have no events yet, nothing needs to happen. A partial replay covers a change that only adds event types, with explicit mappings that don’t overlap existing ones, where every instance belongs to a single event source. For a reducer, added event types must occur only after the events it already consumed for each affected event source. Chronicle applies just those event types to the affected event sources. Anything touching ordering, keys, joins, children, removal, existing mappings, filters, auto-mapping or subscriptions to every event gets a full replay, and so does any change where Chronicle can’t prove the parts are independent. The definitionEvolution setting decides what happens next. Automatic, the default, carries the work out. PartialOnly does the partial work and raises a recommendation when a full replay is needed. Manual raises a recommendation for any replay. Reducer registrations carry a fingerprint of their methods, so changing the code triggers the comparison even when the subscribed event types stay the same. A reducer implementation change needs a full replay.
Retiring a projection happens by leaving it out. When a full-set registration no longer declares it, the kernel unsubscribes its observer in every namespace, deletes its jobs and failed partitions, and removes its definition. The sink’s data stays, for you to remove when you’re ready. A renamed read model shares its container name with the old one, so the kernel raises a replay recommendation for the successor.

The kernel classifies each change, and the setting decides who acts on it.
Rebuilding from history
Section titled “Rebuilding from history”Read models are derived and disposable. When one is wrong, fix the projection and rebuild it by replaying the sequence from the beginning, from the Workbench or with cratis chronicle observers replay. The CLI marks the command destructive and asks for confirmation. A replay reprocesses the event content Chronicle exposes, including applicable revisions and redaction markers, and the cost grows with the length of the history. Reducers rebuild the same way.
On the MongoDB sink, a replay writes into a shadow collection of its own. When it finishes with at least one document, the new collection is renamed into place and the old one is kept under a revert name, with ReplayedVersionsToKeep at 1 by default. A replay that writes nothing leaves the existing collection as it was. The declared indexes are created again on the new collection. The shadow collection belongs to the MongoDB sink.
What a replay rebuilds is derived state. It doesn’t prove that an email went out, which is why side effects live in reactors and not in projections. Reacting to facts covers replay handlers, replay exclusion and the clients that support them.
A projection that got a detail wrong last month can be corrected today and rebuilt from the same events, and a read model nobody has thought of yet can be built later from events that are being recorded now. Adding a column to the author list or fixing a mapping costs a projection change and a replay whose time grows with the log. The events stay as they were.
- Previous: Part 02 — Inside Chronicle
- Next: Part 04 — Reacting to facts