Skip to content

Event source scenarios

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. For the rules behind them, read 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.

ScenarioShows
A bank account with monthly streamsStream dimensions narrow the guard, source dimensions widen it
A stream per ownerWriters on different streams of one event source do not conflict
A process streamA reactor that follows one stream, and a stream that ends
A transfer between two accountsOne atomic append across two event sources

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.

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

The scopes this produces:

AppendScope 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.

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.

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

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 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.

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

  • 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 for what that attribute does and does not promise.

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.

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

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 for what that means when one fact concerns two entities.