Event source definition scenarios
Use this page when you know the shape of the stream you need and want the command. Each scenario states the shape, names one recommended way with the reason, shows a complete definition and command, says what the append is checked against, and lists the alternatives. They follow Route a command through an event source definition; read that page first for the declarations.
| Shape | Event source id | Stream | Stream id |
|---|---|---|---|
| Per entity | The entity | One stream | Default |
| Per owner and period | The report | One stream | {Owner}:{Period} |
| Per process | The customer | One stream | {ProcessId} |
One boundary note applies to all three: a definition scope and a stream decision guard the whole source or stream, whatever the event type. In Dynamic Consistency Boundary terms that is a coarse, aggregate-like boundary. It is right when every event in the stream matters to the decisions made against it, which is why these examples use short, cohesive streams.
Chronicle has one event source per event. Every shape here chooses a stream within one event source; a rule over several entities is several events, one per event source, in one batch. See Chronicle’s DCB scenarios for that side.
A stream per entity
Section titled “A stream per entity”Shape: everything that happens to one account is one history, and a competing write to the same account conflicts.
Recommended: a definition with the EventSourceId dimension and one named stream, and [EventSource<TSource>(stream)] on the command. The reason: the stream and its guard are declared once and typo-checked. If one unnamed history per account is enough, you need no definition: the command key and the default append already give that boundary.
using Cratis.Arc.Chronicle.Commands;using Cratis.Arc.Commands.ModelBound;using Cratis.Chronicle.Events;using Cratis.Chronicle.EventSources;
[EventSource("Account", Concurrency = ConcurrencyDimensions.EventSourceId)][EventStream("Transactions")]public class AccountEventSource : IEventSource;
[EventType]public record TransactionRecorded(decimal Amount);
[Command][EventSource<AccountEventSource>("Transactions")]public record RecordTransaction(EventSourceId AccountId, decimal Amount){ public TransactionRecorded Handle() => new(Amount);}The event lands on event source AccountId, event source type Account, stream type Transactions, default stream id. The dimension is on the event source, so the Transactions stream inherits it. The append is checked against all events for the same event source id, of any type.
This is the default append check: it does not bind Handle()’s decision to what it read. For a balance rule, use a protected decision or an aggregate.
Alternatives: [EventStreamType("Transactions")] and [EventSourceType("Account")] strings (ARCCHR0013 suggests the definition); an aggregate root when the account has rich behaviour; an exact scope when the caller supplies the revision it saw.
A stream per owner and period
Section titled “A stream per owner and period”Shape: a report is approved once per owner and month. Another owner’s month, and another month of the same owner, are independent.
Recommended: a template in [EventStreamId] on a definition-routed command. The reason: it is one attribute, the id is built the same way for the append, a read and a completion, and EventStreamIdTemplate.ResolveFor(command) reuses it in tests.
using Cratis.Arc.Chronicle.Commands;using Cratis.Arc.Commands.ModelBound;using Cratis.Chronicle.Events;using Cratis.Chronicle.EventSources;
[EventSource("Report")][EventStream("Approval", Concurrency = ConcurrencyDimensions.EventSourceId | ConcurrencyDimensions.EventStreamType | ConcurrencyDimensions.EventStreamId)]public class ReportEventSource : IEventSource;
[EventType]public record ReportApproved;
[Command][EventSource<ReportEventSource>("Approval")][EventStreamId("{Owner}:{Period}")]public record ApproveReport(EventSourceId ReportId, string Owner, DateOnly Period){ public ReportApproved Handle() => new();}For Owner = "acme" and Period = 2026-10-01, the append targets event source ReportId, stream type Approval, stream id acme:2026-10-01. The Approval stream declares its own dimensions, so the check is narrowed to events with that stream type and stream id for the report. A write to acme:2026-11-01 is not part of it.
The template reads public properties of the command. Arc resolves it after validation, so a validator that rejects a blank Owner runs first. Keep the id deterministic: two commands that compute different ids for the same period do not conflict.
Alternatives: ICanProvideEventStreamId when code computes the id and a template cannot express it; [EventStreamType] and [EventStreamId] strings without a definition.
Decide against the stream
Section titled “Decide against the stream”Shape: the same report, but approving twice must not record a second approval, and two simultaneous approvals must not both succeed.
Recommended: a stream decision, because the rule is “has this fact already happened in this stream” and the facts are raw events in one short stream. Alternatives: a protected decision when the rule needs folded state or several keys, and a constraint when the rule is only uniqueness.
Add the stream decision. ForCommand() reads the stream the command declares, definition and template included, and Append(...) returns the events with an exact scope at the tail it read.
using Cratis.Arc.Chronicle.Commands;using Cratis.Arc.Chronicle.Streams;using Cratis.Arc.Commands.ModelBound;using Cratis.Chronicle.Events;using Cratis.Chronicle.EventSequences;using Cratis.Chronicle.EventSources;
[EventSource("Report")][EventStream("Approval", Concurrency = ConcurrencyDimensions.EventSourceId | ConcurrencyDimensions.EventStreamType | ConcurrencyDimensions.EventStreamId)]public class ReportEventSource : IEventSource;
[EventType]public record ReportApproved;
[Command][EventSource<ReportEventSource>("Approval")][EventStreamId("{Owner}:{Period}")]public record ApproveReportOnce(EventSourceId ReportId, string Owner, DateOnly Period){ public Task<StreamDecision> Provide(IStreamReads reads) => reads.ForCommand();
public EventsWithConcurrencyScopes Handle(StreamDecision decision) => decision.Has<ReportApproved>() ? decision.Append([]) : decision.Append(new ReportApproved());}What this guards:
- The tail of
Approval/acme:2026-10-01for this report is captured before the events are read. - A competing append to that stream after the read rejects the command, including when both commands attempt the first append.
- A second approval finds
ReportApprovedand returns an empty append: an identical retry, which succeeds without a check. - A competitor on another stream id, such as another month, does not conflict. Use
reads.StreamType(...)to guard every stream id of the type.
The scope covers every event in the stream, not only ReportApproved, and the whole stream crosses the wire on each command. Keep these streams short and cohesive: a period is a good stream, the life of the company is not. See stream decisions for the exact-scope rules and how a stream decision differs from a decision read.
Test the guard by resolving the route the definition produces and letting a competitor append to it before the command commits. The command must produce a new fact for the guard to apply, so do not seed ReportApproved: that takes the identical-retry branch, which carries no scope. This excerpt assumes the definition and command above:
using Cratis.Arc.Chronicle.Testing.Commands;using Cratis.Arc.Testing.Commands;using Cratis.Chronicle.Events;
await using var scenario = new CommandScenario<ApproveReportOnce>();var id = EventSourceId.New();var route = scenario.EventRoutes.For<ReportEventSource>("Approval").WithStreamId("acme:2026-10-01");scenario.AppendConcurrently(id, route, new ReportApproved());A stream per process
Section titled “A stream per process”Shape: each customer onboarding is its own process with its own id. When the process ends, nothing more may be appended to it, and another customer’s process with the same id stays open.
Recommended: a stream per process id on a definition-routed command, and CompleteStream returned by the command that ends the process. The reason: the command already declares the route, so the completion needs no repeated type or id. Alternatives: route.Complete() from a reactor or another command, and an exact CompleteStream(streamType, streamId) when the target differs from the command’s route.
using Cratis.Arc.Chronicle.Commands;using Cratis.Arc.Chronicle.Streams;using Cratis.Arc.Commands.ModelBound;using Cratis.Chronicle.Events;using Cratis.Chronicle.EventSources;
[EventSource("Customer", Concurrency = ConcurrencyDimensions.EventSourceId)][EventStream("Onboarding", Concurrency = ConcurrencyDimensions.EventSourceId | ConcurrencyDimensions.EventStreamType | ConcurrencyDimensions.EventStreamId)]public class CustomerEventSource : IEventSource;
[EventType]public record OnboardingStepCompleted(string Step);
[EventType]public record OnboardingFinished;
[Command][EventSource<CustomerEventSource>("Onboarding")][EventStreamId("{ProcessId}")]public record CompleteOnboardingStep(EventSourceId CustomerId, string ProcessId, string Step){ public OnboardingStepCompleted Handle() => new(Step);}
[Command][EventSource<CustomerEventSource>("Onboarding")][EventStreamId("{ProcessId}")]public record FinishOnboarding(EventSourceId CustomerId, string ProcessId){ public (OnboardingFinished, CompleteStream) Handle() => (new(), new());}CompleteOnboardingStep appends to event source CustomerId, stream type Onboarding, stream id ProcessId. FinishOnboarding appends the final event and then completes that stream.
Complete the stream
Section titled “Complete the stream”new CompleteStream() takes the stream type and stream id from the command’s resolved route, so it closes Onboarding / ProcessId. Because the command has an event source id, Arc scopes the completion to that customer: another customer’s process with the same id stays open. Details and limits:
- Arc completes the stream only after the command’s events commit. The append and the completion are separate operations with no expected-tail guard between them.
- A later append to the completed stream, from any command, fails with a validation result naming the closed stream.
- The default stream id cannot be completed. A command that declares no stream id gets a validation failure.
- Completing an already completed stream succeeds, so a repeated completion is safe.
- Completion needs Chronicle kernel 19.39 or later.
A reactor or another command can close the same stream from a route instead of repeating the declaration: routes.For<CustomerEventSource>("Onboarding").WithStreamId(processId).Complete() returns the CompleteStream for it. See completing streams for reactors and for asserting completion in a scenario.
Where to go next
Section titled “Where to go next”- How routing works: what Arc does from the attribute to the append.
- Choosing how a command’s rule holds and its scenarios: when a stream decision is the right guard.
- Chronicle’s Dynamic Consistency Boundary scenarios and event sources: stream metadata plays the role of DCB tags, and a definition is the typed way to choose which dimensions guard an append.