---
title: Read models scoped to one event stream
editUrl: https://github.com/Cratis/Chronicle/edit/main/Documentation/read-models/per-stream.mdx
description: Declare a passive, source-keyed model for a monthly rule and read only its declared event stream.
lastUpdated: 2026-10-16T00:00:00.000Z
---

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

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.

**C#**

```csharp
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");
    }
}
```

[View C# snippet source on GitHub](https://github.com/Cratis/Chronicle/blob/main/Documentation/client-snippets/read-models/per-stream/monthly-limit.md)

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

| 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](/chronicle/code-analysis/chr0064/) reports statically visible misuse; registration also rejects invalid declarations. [How guards are derived](/chronicle/dynamic-consistency-boundary/how-guards-are-derived/#per-stream-read-models) 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

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](/chronicle/event-sources/defining/).

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](/chronicle/dynamic-consistency-boundary/limits/#what-is-outside-the-guard). Keep histories short enough to fold on demand; this is not a replacement for materialized list or live queries.
