Skip to content

Route a command through an event source definition

An event source definition is a class that names a kind of event source and the streams it owns. A command that declares [EventSource<TSource>(stream)] appends its events through that definition instead of through loose strings. The definition supplies the event source type, the stream type and the concurrency dimensions, and Arc rejects a stream the definition does not declare.

Use this guide when a command should append to a named stream and you want its routing and its concurrency boundary declared once, in one typed place, instead of as strings on each command. A definition gives the append a boundary; it does not make a decision hold. What the definition guards says exactly what you get, and choosing how a command’s rule holds tells you what to add when the rule depends on state.

The definition is a Chronicle concept; see Chronicle event sources for how Chronicle registers and stores it. This page covers what Arc does with it.

The definition is a class implementing IEventSource, marked [EventSource] and one [EventStream] per stream. Without an explicit name, the event source name is the class name with a trailing EventSource removed.

using Cratis.Arc.Chronicle.Commands;
using Cratis.Arc.Commands.ModelBound;
using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSources;
[EventSource("Account")]
[EventStream("Transactions", Concurrency = ConcurrencyDimensions.EventSourceId | ConcurrencyDimensions.EventStreamType)]
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 is appended with:

  • event source type Account, from the definition name;
  • event stream type Transactions, from the stream you named;
  • the AccountId as event source id, resolved by the usual event source id conventions;
  • the default stream id, because the command declares none.

The command’s namespace needs Cratis.Arc.Chronicle.Commands for [EventSource<TSource>] and Cratis.Chronicle.EventSources for the definition types. The definition must be discovered by the Chronicle client like any other artifact; an unregistered definition type fails when the command runs.

A command declares at most one [EventSource<TSource>]. The stream argument is optional: without it the command routes through the definition’s source type with the default stream type.

A stream is the part of an event source’s history that one kind of decision shares. Pick the stream by asking which events must be checked against each other when two commands run at once.

Recommended: one definition per kind of entity, one [EventStream] per history that is decided separately, and the event source id as the entity’s id. Name the stream on the command with [EventSource<TSource>(stream)]. The reason: the definition is the single place that says which streams exist and which dimensions guard them, and Arc rejects a typo. Alternatives, in order of preference: the command key and default stream with no definition, when one history per entity is enough; string attributes ([EventSourceType] with [EventStreamType]), which ARCCHR0013 flags once a matching definition exists; explicit scopes, when the decision must be bound to a revision it read.

You wantDeclareStream id
One history per entity, everything for it togetherOne stream, or noneLeave unset; the event source id identifies the entity
Independent histories of one entity, such as Onboarding and TransactionsOne [EventStream] per historyLeave unset
One history per entity and period, owner or processOne streamA template or ICanProvideEventStreamId, see below

EventStream on the attribute must name a stream the definition declares. A typo or an undeclared name fails when the command’s context is built, with EventRoutingContradictsEventSource, instead of appending to a stream nobody reads. Scenarios shows each shape as a complete command.

The definition names the stream. The stream id is separate: it picks one concrete stream within the stream type, and the definition does not supply it. Declare it on the command with the existing stream id mechanisms:

  • [EventStreamId("constant")] for a fixed id.
  • ICanProvideEventStreamId when code computes it.
  • A template in [EventStreamId("{Owner}:{Period}")] when it is composed from command properties.
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 stream id is acme:2026-10-01. A template part is a public instance property. Concepts use their underlying value, DateOnly uses yyyy-MM-dd, and {{ and }} are literal braces; the full format table lists the rest. Arc refuses unknown properties, blank parts, malformed braces and an id that resolves to the default, and it resolves the template after validation, so a validator can reject a missing part first.

Use the same function when you read or seed the stream: EventStreamIdTemplate.ResolveFor(command) returns the id exactly as the pipeline resolves it.

Combining a non-empty [EventStreamId] with ICanProvideEventStreamId is ambiguous and throws AmbiguousEventStreamId.

Chronicle’s Dynamic Consistency Boundary (DCB) selects the events an append must not have missed with tags. In Chronicle the stream metadata plays that role: event source id, event source type, stream type and stream id. An event source definition is the typed way to choose which of those dimensions guard an append. The concurrency dimensions on the definition decide which events a returned event is checked against. [EventSource] can declare dimensions for every stream, and each [EventStream] can declare its own; a stream that declares dimensions replaces the source’s for that stream, and a stream that declares none inherits the source’s. The dimensions are a combination of:

DimensionThe append is checked against events with the same
EventSourceIdEvent source id. Leave it out only when the guard should span every event source that writes the stream, and only on a definition-routed EventForEventSourceId; see below.
EventSourceTypeEvent source type, the definition name
EventStreamTypeStream type, the stream name
EventStreamIdStream id, from [EventStreamId]

With no dimensions declared, the definition adds no guard of its own and the event sequence applies its configured strategy as it does for any append.

Which dimensions the scope carries depends on how the event is returned. A definition-routed EventForEventSourceId, single or in a returned collection of wrappers (EventSource set, or built with IEventRoutes), is guarded by the scope Chronicle derives from the definition, which carries exactly the declared dimensions (when the command’s definition declares any), so a definition without EventSourceId is checked against all sources’ events with the same stream type and stream id. For every other returned event, that is a plain event, a collection of plain events, or a legacy-string wrapper, Arc builds the scope itself and always includes the target event source id, then adds the declared stream type, stream id and source type. Omitting EventSourceId therefore gives no cross-source guard there. Do not rely on a cross-source boundary from a plain returned event; include EventSourceId on the definition so both paths agree, or return definition-routed events. How routing works lists each return path.

Four limits matter:

  • It is the default append check, not a decision guard. Arc resolves the expected tail while it processes the returned events, after Handle() has run. A concurrent append between your read and that moment is part of the new expected tail and does not conflict. The first append into an empty scope is checked only when the client’s concurrency options opt in. To bind a decision to what it read, use a stream decision, a protected decision or exact scopes.
  • An explicit choice wins. concurrency: true on [EventStreamId], [EventStreamType] or [EventSourceType] selects the dimensions from those attributes, and a returned EventsWithConcurrencyScopes replaces every derived scope. The definition stays the policy for every command that does not override it.
  • It guards the whole source or stream, whatever the event type. The scope narrows by event source id, source type, stream type and stream id, never by event type. In DCB terms that is a coarse, aggregate-like boundary: any new event in the guarded stream conflicts, related or not. That is the right shape when the events in the stream are all relevant to the decisions made against it, for example a short history per entity or per period. When unrelated event types share the stream, give the decision its own stream, or use a protected decision, whose scope narrows to the event types its model folds. A stream decision has the same whole-stream boundary and also reads the whole stream.
  • One event source per event. The boundary is an event source id plus the dimensions above. Chronicle does not tag one event with several entities, so a fact about two entities is two events, one per event source, appended in one atomic batch. See Chronicle’s Dynamic Consistency Boundary for how a concurrency scope maps to the boundary and when to use which mechanism.

How routing works follows a single append from the attribute to the scope Chronicle receives.

The route the command declares is the same route its reads and completions use:

  • IStreamReads.ForCommand() reads the command’s stream at a captured tail and returns a StreamDecision whose Append(...) carries an exact scope.
  • CompleteStream closes the command’s stream after its events commit.
  • IEventRoutes resolves a route for any definition outside a command; see per-event routing.

Commands without a definition keep working. To move one over:

  1. Declare an IEventSource with [EventSource("Name")] and an [EventStream] for each stream type the command used. Use the strings you already store as the names so existing events and observers match.
  2. Replace [EventSourceType("Name")] and [EventStreamType("Stream")] on the command with [EventSource<TSource>("Stream")]. Keep [EventStreamId] and ICanProvideEventStreamId; the definition does not replace them.
  3. Move concurrency: true to dimensions on the definition. While a command still carries the flag, the flag chooses the dimensions and the definition’s are ignored for it.

ARCCHR0013 (Info) finds the commands to migrate: when [EventSourceType("Name")] on a command or aggregate root names a source that a definition in the compilation declares, it suggests [EventSource<TSource>], or [EventSource<TSource>("stream")] when an [EventStreamType] names a stream the definition declares. It stays silent when the stream is not declared, and for a type that already has [EventSource<TSource>]. It has no code fix, because the attributes can carry concurrency: true flags that the definition’s dimensions do not reproduce.

Do not keep a string attribute that contradicts the definition. [EventSourceType] or [EventStreamType] with a value other than the definition’s source or stream fails with EventRoutingContradictsEventSource rather than append events whose strings disagree with the definition.

A returned EventForEventSourceId can name its route with EventSourceType and EventStreamType strings. That still works, but the event is appended by those strings: it loses EventContext.EventSource and the wrapper target’s own definition policy. On a definition-routed command Arc still applies the command’s selected definition dimensions, and builds the scope from the wrapper’s own routing strings, always including the target event source id. Two analyzers help you migrate:

  • ARCCHR0021 (Info) finds literal strings that spell a declared stream. Its code fix replaces them with EventSource = typeof(Definition) and EventStream = "stream", or you can use IEventRoutes. Review the concurrency change first, because the guard now comes from the definition, which Chronicle derives with exactly the declared dimensions rather than Arc’s scope that always includes the event source id.
  • ARCCHR0020 (Warning) finds a literal stream name that the definition does not declare, which is usually a typo. At runtime a definition-routed command rejects such a wrapper with EventStreamDoesNotBelongToEventSource before it enrolls or appends anything.
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("Account")]
[EventStream("Transactions")]
[EventStream("Adjustments")]
public class AccountEventSource : IEventSource;
[EventType]
public record BalanceAdjusted(decimal Amount);
[Command]
[EventSource<AccountEventSource>("Transactions")]
public record AdjustAccount(EventSourceId AccountId, decimal Amount)
{
// Before: new EventForEventSourceId(AccountId, new BalanceAdjusted(Amount))
// { EventSourceType = "Account", EventStreamType = "Adjustments" }
public EventForEventSourceId Handle(IEventRoutes routes) =>
routes.For<AccountEventSource>("Adjustments").Route(AccountId, new BalanceAdjusted(Amount));
}