---
title: Event source scenarios
editUrl: https://github.com/Cratis/Chronicle/edit/main/Documentation/event-sources/scenarios.mdx
description: Worked examples of event source definitions, with the append, the concurrency scope it produces and what conflicts and what does not.
---

import { Tabs, TabItem } from '@astrojs/starlight/components';


Use this page to see how a definition behaves in a realistic domain. Each scenario shows the definition, the appends, the scope Chronicle produces and what that scope does to concurrent writers. The scenarios assume the default settings, where [the first append into a scope is not checked](/chronicle/events/concurrency/#the-first-append-into-a-scope-is-not-checked-by-default). For the rules behind them, read [Concurrency](/chronicle/event-sources/concurrency/).

Each scenario names one recommended way and the reason, then the alternatives. Definition-derived guards cover a whole event source or stream, whatever the event type, so they suit cases where the stream is the consistency boundary. For a narrower condition, use a [decision read](/chronicle/read-models/decision-reads/).

| Scenario | Shows |
| --- | --- |
| [A bank account with monthly streams](#a-bank-account-with-monthly-streams) | Stream dimensions narrow the guard, source dimensions widen it |
| [A stream per owner](#a-stream-per-owner) | Writers on different streams of one event source do not conflict |
| [A process stream](#a-process-stream) | A reactor that follows one stream, and a stream that ends |
| [A transfer between two accounts](#a-transfer-between-two-accounts) | One atomic append across two event sources |

## A bank account with monthly streams

An account has an `Onboarding` stream that exists once, and a `Transactions` stream with one stream id per month. Customers deposit all the time, so deposits in different months must not interfere. Changes to the account's onboarding data must see everything that happened to the account.

**Recommended:** declare the dimensions on the definition, with the stream's own dimensions on `Transactions`. The month is the consistency boundary: deposits in one month must see each other, and nothing else needs to. **Alternatives:** build an explicit scope per call, which repeats the dimension choice at every call site, or use a decision read when the rule depends on state, such as a balance or a limit. See [Dynamic Consistency Boundary](/chronicle/event-sources/dcb/).

<Tabs syncKey="chronicle-client">
<TabItem label="C#">

```csharp
using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.EventSources;

[EventType]
public record AccountOpened(string Owner);

[EventType]
public record IdentityVerified(string Method);

[EventType]
public record MoneyDeposited(decimal Amount);

[EventType]
public record MoneyWithdrawn(decimal Amount);

[EventSource("Account", Description = "A customer's bank account", Concurrency = ConcurrencyDimensions.EventSourceId)]
[EventStream("Onboarding", Description = "Opening the account and verifying the customer")]
[EventStream(
    "Transactions",
    Description = "Deposits and withdrawals, one stream per month",
    Concurrency = ConcurrencyDimensions.EventSourceId | ConcurrencyDimensions.EventStreamType | ConcurrencyDimensions.EventStreamId)]
public class AccountEventSource : IEventSource;

public class AccountService(IEventLog eventLog)
{
    public Task<AppendResult> Open(EventSourceId accountId, string owner) =>
        eventLog.Append<AccountEventSource>(accountId, new AccountOpened(owner), eventStream: "Onboarding");

    public Task<AppendResult> Verify(EventSourceId accountId, string method) =>
        eventLog.Append<AccountEventSource>(accountId, new IdentityVerified(method), eventStream: "Onboarding");

    public Task<AppendResult> Deposit(EventSourceId accountId, string month, decimal amount) =>
        eventLog.Append<AccountEventSource>(
            accountId,
            new MoneyDeposited(amount),
            eventStream: "Transactions",
            eventStreamId: month);
}
```

[View C# snippet source on GitHub](https://github.com/Cratis/Chronicle/blob/main/Documentation/client-snippets/event-sources/scenarios/bank-account.md)

</TabItem>
</Tabs>

The scopes this produces:

| Append | Scope narrows to |
| --- | --- |
| `Deposit(a1, "2026-04", …)` | account `a1`, stream type `Transactions`, stream id `2026-04` |
| `Verify(a1, …)` | every event of account `a1`, because `Onboarding` declares no dimensions of its own and inherits `EventSourceId` |

What conflicts, when both writers read before either appends:

- Two deposits to `a1` in `2026-04` conflict. The second is rejected and nothing is written for it.
- A deposit to `a1` in `2026-05` does not conflict with a deposit in `2026-04`.
- A deposit to account `a2` does not conflict with anything on `a1`.
- `Verify` after a deposit to the same account conflicts, because its scope covers the whole account. A deposit after `Verify` does not conflict, because its scope covers only the month.

The first deposit of each new month is not checked by default. Two writers that open `2026-06` at the same time both succeed. To check it, turn on `CheckFirstAppendIntoAScope`, as described in [Concurrency](/chronicle/event-sources/concurrency/#the-first-append-into-a-scope).

## A stream per owner

A project collects notes from several authors. Authors write at the same time and should not wait for each other. The notes of one author are ordered and must not interleave with themselves.

**Recommended:** one stream per author, with the stream id as the author id and all three stream dimensions declared. Authors then cannot conflict with each other, and one author's notes stay ordered. **Alternatives:** one stream for all notes, which makes every author conflict with every other, or one event source per author, which loses the project as the common key.

<Tabs syncKey="chronicle-client">
<TabItem label="C#">

```csharp
using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.EventSources;

[EventType]
public record NoteAdded(string Text);

// One stream per author: authors write notes on the same project without conflicting with each other.
[EventSource("Project", Concurrency = ConcurrencyDimensions.EventSourceId)]
[EventStream(
    "Notes",
    Description = "Notes on a project, one stream per author",
    Concurrency = ConcurrencyDimensions.EventSourceId | ConcurrencyDimensions.EventStreamType | ConcurrencyDimensions.EventStreamId)]
public class ProjectEventSource : IEventSource;

public class NoteService(IEventLog eventLog)
{
    public Task<AppendResult> AddNote(EventSourceId projectId, string authorId, string text) =>
        eventLog.Append<ProjectEventSource>(
            projectId,
            new NoteAdded(text),
            eventStream: "Notes",
            eventStreamId: authorId);
}
```

[View C# snippet source on GitHub](https://github.com/Cratis/Chronicle/blob/main/Documentation/client-snippets/event-sources/scenarios/per-owner.md)

</TabItem>
</Tabs>

The stream id is the author id. The `Notes` stream declares the event source id, the stream type and the stream id, so the scope narrows to one author's notes on one project.

- Two authors adding notes to the same project do not conflict.
- The same author adding notes from two devices at once conflicts on the second append. Read the new tail and append again.
- Appends to the project's other streams, or to other projects, are not affected.

The project's own event source declares `EventSourceId`, so an append that carries no stream is guarded against every event of the project, including every author's notes. Put project-wide facts in a stream of their own if they should not conflict with note-taking.

## A process stream

A money transfer is a process with several steps. Each transfer is one event source, and its steps are events in one `Process` stream. A reactor drives the process, and a closing event ends it.

**Recommended:** one event source per transfer with a `Process` stream, a typed observer filter, and a closing event. The process is the consistency boundary, so the coarse guard is what you want, and the closing event makes the end final for every writer. **Alternatives:** string filters with `[EventSourceType]` and `[EventStreamType]`, which the analyzer cannot check, or a manual `CompleteStream` call, which is a second step after the final append.

<Tabs syncKey="chronicle-client">
<TabItem label="C#">

```csharp
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.EventSources;
using Cratis.Chronicle.Reactors;

[EventType]
public record TransferRequested(string From, string To, decimal Amount);

[EventType]
public record TransferFundsReserved;

[EventType]
[ClosesStream]
public record TransferCompleted;

// One event source per transfer, and one stream for the process that carries it out.
[EventSource("Transfer", Concurrency = ConcurrencyDimensions.EventSourceId)]
[EventStream("Process", Description = "The steps of one transfer")]
public class TransferEventSource : IEventSource;

// Only the steps of a transfer's own process reach this reactor.
[FromEventSource<TransferEventSource>("Process")]
public class TransferProcess(IEventLog eventLog) : IReactor
{
    public async Task Requested(TransferRequested @event, EventContext context)
    {
        // Reserve the funds, then record the step on the same stream.
        await eventLog.Append<TransferEventSource>(context.EventSourceId, new TransferFundsReserved(), eventStream: "Process");
    }

    public async Task Reserved(TransferFundsReserved @event, EventContext context)
    {
        // The closing event ends the stream: nothing more can be appended to it.
        await eventLog.Append<TransferEventSource>(context.EventSourceId, new TransferCompleted(), eventStream: "Process");
    }
}

public class TransferStarter(IEventLog eventLog)
{
    public Task<AppendResult> Start(EventSourceId transferId, string from, string to, decimal amount) =>
        eventLog.Append<TransferEventSource>(transferId, new TransferRequested(from, to, amount), eventStream: "Process");
}
```

[View C# snippet source on GitHub](https://github.com/Cratis/Chronicle/blob/main/Documentation/client-snippets/event-sources/scenarios/process.md)

</TabItem>
</Tabs>

- The reactor uses `[FromEventSource<TransferEventSource>("Process")]`. It receives the steps of transfer processes and nothing else.
- Every append declares the event source id only. The scope narrows to the one transfer, so transfers do not conflict with each other, and two writers advancing the same transfer do.
- `TransferCompleted` carries `[ClosesStream]`. Appended to the `Process` stream, it closes that transfer's process stream. A step appended afterwards is refused with `StreamClosed`, so a late or duplicated message cannot reopen a finished transfer.

A reactor reacts after the event is written. It is not a guard, and it can be given an event again after a recovery. Make each step safe to repeat, and see [once only](/chronicle/reactors/once-only/) for what that attribute does and does not promise.

## A transfer between two accounts

Moving money between two accounts writes one event to each account. It is one logical operation, so both events are written together or not at all. This scenario uses the definition and events from the first scenario.

**Recommended:** one atomic `AppendMany` with one event per account, routed through the definition, so each account gets its own month guard. **Alternatives:** two separate appends, which can leave one side written, or a decision read per account when the transfer depends on a balance. See [A rule across two keys](/chronicle/dynamic-consistency-boundary/scenarios/#a-rule-across-two-keys).

<Tabs syncKey="chronicle-client">
<TabItem label="C#">

```csharp
using Cratis.Chronicle.EventSequences;

public class AccountTransfers(IEventLog eventLog)
{
    // Uses the Account definition and events from the first scenario.
    public Task<AppendManyResult> Move(EventSourceId from, EventSourceId to, string month, decimal amount) =>
        eventLog.AppendMany(
        [
            new EventForEventSourceId(from, new MoneyWithdrawn(amount))
            {
                EventSource = typeof(AccountEventSource),
                EventStream = "Transactions",
                EventStreamId = month
            },
            new EventForEventSourceId(to, new MoneyDeposited(amount))
            {
                EventSource = typeof(AccountEventSource),
                EventStream = "Transactions",
                EventStreamId = month
            }
        ]);
}
```

[View C# snippet source on GitHub](https://github.com/Cratis/Chronicle/blob/main/Documentation/client-snippets/event-sources/scenarios/transfer.md)

</TabItem>
</Tabs>

Each event source id gets its own scope, built from the definition: account `from`, `Transactions`, the month; and account `to`, `Transactions`, the month. The two scopes are checked against the state before the batch.

- If either account got a deposit or a withdrawal in that month after the tail was read, the whole append is rejected, and neither event is written.
- A concurrent deposit to either account in another month does not matter.

This records the transfer as two events, one on each account, because every event has exactly one event source. See [Dynamic Consistency Boundary in Chronicle](/chronicle/dynamic-consistency-boundary/chronicle/) for what that means when one fact concerns two entities.

## Next steps

- [How it works](/chronicle/event-sources/how-it-works/)
- [Dynamic Consistency Boundary](/chronicle/event-sources/dcb/): a guarded decision on top of a stream.
- [Questions and pitfalls](/chronicle/event-sources/faq/)
