Read models scoped to one event stream
Use a per-stream read model when a rule depends on folded state for one stream, such as withdrawals for one account in one month. A whole-account balance would mix months; keying only by month would collide across accounts. Keep the account as the model’s Id and scope the fold to the stream instead.
| Your rule needs | Use |
|---|---|
| Folded state in one stream | A per-stream read model |
| The account’s whole history | A whole-source read model |
| Rich behavior across the whole entity | An aggregate root |
| A unique value across sources | A constraint |
| Another stream or source | An explicit stream-scoped decision read |
Declare the model and guard the decision
Section titled “Declare the model and guard the decision”Declare [EventStream<TSource>("Transactions")] from Cratis.Chronicle.ReadModels on the model. TSource is an IEventSource definition that declares that stream. The non-generic [EventStream("Transactions")] belongs on the event source definition, not the read model.
This example limits withdrawals to 10,000 in BankAccount/Transactions/2026-10. It assumes a connected event store with the definition, event and model discovered through the normal client setup, and a positive withdrawal amount.
namespace MonthlyLimitExample{ using Cratis.Chronicle; using Cratis.Chronicle.Events; using Cratis.Chronicle.EventSequences; using Cratis.Chronicle.EventSources; using Cratis.Chronicle.Projections.ModelBound; using Cratis.Chronicle.ReadModels;
public record AccountId(string Value) : EventSourceId(Value);
[EventSource("BankAccount")] [Cratis.Chronicle.EventSources.EventStream("Transactions")] public class BankAccountEventSource : IEventSource;
[EventType] public record MoneyWithdrawn(decimal Amount);
[EventStream<BankAccountEventSource>("Transactions")] public record MonthlyWithdrawals( AccountId Id, [AddFrom<MoneyWithdrawn>(nameof(MoneyWithdrawn.Amount))] decimal Withdrawn);
public record WithdrawalOutcome(bool LimitReached, AppendManyResult? Append);
public static class OctoberWithdrawals { public static async Task<WithdrawalOutcome> Withdraw( IEventStore eventStore, AccountId account, decimal amount) { var definition = eventStore.EventSources.GetFor(typeof(BankAccountEventSource)); EventStreamId month = "2026-10"; var read = await eventStore.GetDecisionReads().GetDetached<MonthlyWithdrawals>( account, "Transactions", month, definition.EventSourceType); if ((read.Instance?.Withdrawn ?? 0m) + amount > 10_000m) { return new(true, null); }
var result = await eventStore.EventLog.AppendMany( [new EventForEventSourceId(account, new MoneyWithdrawn(amount)) { EventSourceType = definition.EventSourceType, EventStreamType = "Transactions", EventStreamId = month }], guardedBy: [read]); return new(false, result); }
public static Task<MonthlyWithdrawals> ForAccount( IEventStore eventStore, AccountId account) => eventStore.ReadModels.GetInstanceById<MonthlyWithdrawals>(account, "2026-10"); }}The attribute makes both model-bound and fluent projections passive: there is no stored account instance that could accumulate several months. MonthlyWithdrawals.Id remains the account ID. Each read folds only that account, the declared event source type, the stream type and the requested month.
Withdraw checks the folded total and carries the decision read into the append. A matching withdrawal appended after the read makes the append fail with a concurrency violation; it writes nothing. A withdrawal in 2026-11 does not affect this fold or invalidate its guard. Check WithdrawalOutcome.Append.IsSuccess before treating the withdrawal as accepted; after a conflict, read again and re-evaluate the rule rather than resubmitting a stale decision.
ForAccount uses the stream-aware IReadModels.GetInstanceById<T>(EventSourceId, EventStreamId) overload. This is a plain read: it supplies the same stream instance for a query but guards no later append. When the stream has not created an instance, it returns null.
That overload throws DecisionReadRefused with StreamScopeNotSupported when the IReadModels implementation does not support stream scope. The interface’s default implementation always throws it, so a custom or test double of IReadModels must override the overload. The client’s own implementation also throws it when the kernel does not acknowledge stream scope.
Reads must name the stream
Section titled “Reads must name the stream”| Read | Result |
|---|---|
IDecisionReads.Get, GetFor or GetDetached with source, stream type, stream ID and the declaration’s source type | A decision read scoped to that stream |
IReadModels.GetInstanceById<T>(source, streamId) | A plain read; stream type and source type come from the declaration |
Key-only decision reads or key-only IReadModels.GetInstanceById | DecisionReadRefused with ReadModelRequiresStreamScope |
GetInstances, snapshots, Observe, Watch or GetWatcherFor | Refused; there is no materialized collection or key-only stream identity |
| A decision read with a different stream type or source type, including an omitted source type | StreamScopeDoesNotMatchReadModel |
Reducers, stream-keyed models, undeclared streams and EventStreamType.All are not valid declarations. CHR0064 reports statically visible misuse; registration also rejects invalid declarations. How guards are derived shows the guard such a read produces.
Supply a specific month. EventStreamId.Default does not narrow by stream ID: it folds every ID of the declared stream type. The client permits that explicit choice, but it is not a monthly rule.
Keep every writer on the same route
Section titled “Keep every writer on the same route”The declaration’s event source type is part of both the fold and the guard. An event written to Transactions/2026-10 without the BankAccount source type is neither folded nor guarded. Every writer participating in this rule must use the definition’s route and protect its decision. See event source definitions.
Reading a whole-source model as well, or another stream for the same source, widens the merged guard to the whole source. Source-level declared concurrency can also widen it. That adds conflicts; it does not lose protection. A per-stream model does not promise a narrow guard when other enrolled reads or declared concurrency require more history.
Stream-scoped reads require a kernel that acknowledges stream scope. An older kernel refuses with StreamScopeNotSupported rather than returning a whole-source fold. Revision, redaction, external data and definition changes remain outside the guard. See Costs and limits. Keep histories short enough to fold on demand; this is not a replacement for materialized list or live queries.