Reacting to Read Model Changes
Watching a read model hands you a raw stream of changes: you subscribe, filter, branch on whether the instance was removed, and dispose the subscription yourself. When all you want is “when an account appears, send a welcome; when it changes, sync it downstream”, that plumbing is noise.
A read model reactor removes it. You write a class, name a method after the change you care about — Added, Modified or Removed — and Chronicle dispatches to it. It is a convenience layer over the Watch APIs: the same delivery underneath, none of the subscription bookkeeping.
Write the reactor
Section titled “Write the reactor”Implement the IReadModelReactor marker interface and add a method named Added, Modified or Removed. The method name selects the change it reacts to, and the first parameter selects which read model is watched.
using Cratis.Chronicle.ReadModels;
public class AccountNotifier : IReadModelReactor{ public Task Added(Account account) => SendWelcome(account);
public Task Modified(Account account) => SendUpdated(account);
public Task Removed(Account account) => SendClosed(account);
Task SendWelcome(Account account) => Task.CompletedTask; Task SendUpdated(Account account) => Task.CompletedTask; Task SendClosed(Account account) => Task.CompletedTask;}import io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.readModels.ReadModelReactorsimport kotlinx.coroutines.Jobimport io.cratis.chronicle.readModels.IReadModelReactorimport io.cratis.chronicle.readModels.ReadModel
@ReadModeldata class ReactingReactorAccount(val name: String = "", val balance: Double = 0.0)
class AccountNotifier : IReadModelReactor { fun added(account: ReactingReactorAccount) = sendWelcome(account) fun modified(account: ReactingReactorAccount) = sendUpdated(account) fun removed(account: ReactingReactorAccount?) = sendClosed(account)
private fun sendWelcome(account: ReactingReactorAccount) { /* ... */ } private fun sendUpdated(account: ReactingReactorAccount) { /* ... */ } private fun sendClosed(account: ReactingReactorAccount?) { /* ... */ }}
// JVM read model reactors are registered explicitly; cancel the job on shutdown.fun startAccountNotifier(store: IEventStore): Job = ReadModelReactors(store.readModels, store.eventLog).register(AccountNotifier())import io.cratis.chronicle.IEventStore;import io.cratis.chronicle.readModels.ReadModelReactors;import kotlinx.coroutines.Job;import io.cratis.chronicle.readModels.IReadModelReactor;import io.cratis.chronicle.readModels.ReadModel;
@ReadModelclass ReactingReactorAccount { private String name = ""; private double balance = 0;
public String getName() { return name; } public void setName(String name) { this.name = name; }
public double getBalance() { return balance; } public void setBalance(double balance) { this.balance = balance; }}
class AccountNotifier implements IReadModelReactor { public void added(ReactingReactorAccount account) { sendWelcome(account); } public void modified(ReactingReactorAccount account) { sendUpdated(account); } public void removed(ReactingReactorAccount account) { sendClosed(account); }
private void sendWelcome(ReactingReactorAccount account) { } private void sendUpdated(ReactingReactorAccount account) { } private void sendClosed(ReactingReactorAccount account) { }
// JVM read model reactors are registered explicitly; cancel the job on shutdown. static Job start(IEventStore store) { return new ReadModelReactors(store.getReadModels(), store.getEventLog()).register(new AccountNotifier()); }}Elixir does not support this workflow yet.TypeScript does not support this workflow yet.There is nothing to register. Reactors are discovered and started automatically, and their subscriptions are tracked and cleaned up when the client is disposed. React to as many read models as you like — one method per change, per model.
Handle a single instance or a collection
Section titled “Handle a single instance or a collection”A handler may be synchronous or asynchronous — return void, Task, or Task<T>. Its first parameter is either a single read model or a collection of them:
using Cratis.Chronicle.ReadModels;
public class AccountBatchProjector : IReadModelReactor{ public async Task Modified(IEnumerable<Account> accounts) { foreach (var account in accounts) { await Sync(account); } }
Task Sync(Account account) => Task.CompletedTask;}import io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.readModels.ReadModelReactorsimport kotlinx.coroutines.Jobimport io.cratis.chronicle.readModels.IReadModelReactorimport io.cratis.chronicle.readModels.ReadModel
@ReadModeldata class ReactingCollectionAccount(val name: String = "", val balance: Double = 0.0)
class AccountBatchProjector : IReadModelReactor { fun modified(accounts: List<ReactingCollectionAccount>) { accounts.forEach { sync(it) } }
private fun sync(account: ReactingCollectionAccount) { /* ... */ }}
// JVM read model reactors are registered explicitly; cancel the job on shutdown.fun startAccountBatchProjector(store: IEventStore): Job = ReadModelReactors(store.readModels, store.eventLog).register(AccountBatchProjector())import io.cratis.chronicle.IEventStore;import io.cratis.chronicle.readModels.ReadModelReactors;import kotlinx.coroutines.Job;import io.cratis.chronicle.readModels.IReadModelReactor;import io.cratis.chronicle.readModels.ReadModel;
import java.util.List;
@ReadModelclass ReactingCollectionAccount { private String name = ""; private double balance = 0;
public String getName() { return name; } public void setName(String name) { this.name = name; }
public double getBalance() { return balance; } public void setBalance(double balance) { this.balance = balance; }}
class AccountBatchProjector implements IReadModelReactor { public void modified(List<ReactingCollectionAccount> accounts) { accounts.forEach(this::sync); }
private void sync(ReactingCollectionAccount account) { }
// JVM read model reactors are registered explicitly; cancel the job on shutdown. static Job start(IEventStore store) { return new ReadModelReactors(store.getReadModels(), store.getEventLog()).register(new AccountBatchProjector()); }}Elixir does not support this workflow yet.TypeScript does not support this workflow yet.Take dependencies
Section titled “Take dependencies”The first parameter is the read model; every parameter after it is resolved for you. Ask for the EventContext of the event that caused the change — that gives you its sequence number, occurrence time and correlation id — and ask for any service registered in the container. Inject through the constructor, the method signature, or both:
using Cratis.Chronicle.Events;using Cratis.Chronicle.EventSequences;using Cratis.Chronicle.ReadModels;
public interface IAccountNotifications{ Task Notify(string accountId);}
public interface IAccountAuditLog{ void Record(string accountId, EventSequenceNumber sequenceNumber);}
public class AccountAuditor(IAccountNotifications notifications) : IReadModelReactor{ public Task Modified(Account account, EventContext context, IAccountAuditLog audit) { audit.Record(account.Id, context.SequenceNumber); return notifications.Notify(account.Id); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.Return side effects
Section titled “Return side effects”A handler can append events, exactly like a reactor side effect. Return a single event, a collection, an EventForEventSourceId, or a mix, and Chronicle appends them. The [EventStreamType] and [EventSourceType] attributes on the reactor are honored when it does.
using Cratis.Chronicle.Events;using Cratis.Chronicle.ReadModels;
[EventType]public record AccountFlagged(string AccountId);
public class AccountReviewer : IReadModelReactor{ public Task<AccountFlagged> Modified(Account account) => Task.FromResult(new AccountFlagged(account.Id));}import io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.readModels.ReadModelReactorsimport kotlinx.coroutines.Jobimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.readModels.IReadModelReactorimport io.cratis.chronicle.readModels.ReadModel
@EventTypedata class AccountFlagged(val accountId: String = "")
@ReadModeldata class ReactingSideEffectsAccount(val id: String = "", val balance: Double = 0.0)
class AccountReviewer : IReadModelReactor { /** * Returning an event from a handler appends it, using the changed instance's key as the * event source id by default. */ fun modified(account: ReactingSideEffectsAccount): AccountFlagged = AccountFlagged(account.id)}
// JVM read model reactors are registered explicitly; cancel the job on shutdown.fun startAccountReviewer(store: IEventStore): Job = ReadModelReactors(store.readModels, store.eventLog).register(AccountReviewer())import io.cratis.chronicle.IEventStore;import io.cratis.chronicle.readModels.ReadModelReactors;import kotlinx.coroutines.Job;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.readModels.IReadModelReactor;import io.cratis.chronicle.readModels.ReadModel;
@EventTyperecord AccountFlagged(String accountId) {}
@ReadModelclass ReactingSideEffectsAccount { private String id = ""; private double balance = 0;
public String getId() { return id; } public void setId(String id) { this.id = id; }
public double getBalance() { return balance; } public void setBalance(double balance) { this.balance = balance; }}
class AccountReviewer implements IReadModelReactor { // Returning an event from a handler appends it, using the changed instance's key as the // event source id by default. public AccountFlagged modified(ReactingSideEffectsAccount account) { return new AccountFlagged(account.getId()); }
// JVM read model reactors are registered explicitly; cancel the job on shutdown. static Job start(IEventStore store) { return new ReadModelReactors(store.getReadModels(), store.getEventLog()).register(new AccountReviewer()); }}Elixir does not support this workflow yet.TypeScript does not support this workflow yet.The three change types
Section titled “The three change types”| Method | Reacts to |
|---|---|
Added | The read model instance was created for the first time. |
Modified | The read model instance changed. |
Removed | The read model instance was removed. |
For projection-backed read models the change type and the causing event’s context come straight from the server, so Added versus Modified is precise and EventContext is fully populated. Other backings infer more of this locally — see the limits below.
Watch through the materialized read model
Section titled “Watch through the materialized read model”Apply the [Materialized] attribute to observe the materialized read model API instead of the change stream. Chronicle then deduces the change type by comparing successive materialized windows.
using Cratis.Chronicle.ReadModels;
[Materialized]public class AccountSnapshotReactor : IReadModelReactor{ public Task Added(Account account) => Task.CompletedTask;}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.Reach for this only when you specifically want the materialized view — most reactors do not need it, and it trades away some fidelity, described next.
When the fit is imperfect
Section titled “When the fit is imperfect”A read model reactor is convenient, not transactional. Design for that:
- Materialized fidelity is reduced. The materialized API delivers full windows of already-deserialized instances, so the change type is inferred by comparing serialized values keyed by
id, and theEventContextcarries only the model key — no event sequence number. On SQL, several changes within one polling interval can collapse into one[Materialized]dispatch. - Reducer-backed read models infer additions client-side. Reducers compute changesets locally with no server-provided change type, so the first time a key is seen it is reported as
Addedand everything after asModified. Only projection-backed read models carry the precise change type and causing event context.
When you need transactional, ordered, replayable reactions, react to the domain event with a reactor rather than to the derived read model.
Related topics
Section titled “Related topics”- Watching Read Models — the lower-level change stream this builds on
- Reactors — react to events directly, with delivery guarantees
- Reactor Side Effects — the append model reused here