Returning Side Effects from Reactor Handler Methods
Reactor handler methods can return side-effect events directly instead of taking a dependency on IEventLog. The framework automatically appends the returned events to the correct sequence after the handler completes.
Important: Side-effect appends are not fire-and-forget. If a returned event fails to append — a constraint violation, a concurrency conflict, or an error — the reactor’s partition fails with the failure details and is retried per the observer’s retry policy. Failures never pass silently. See Error handling.
Basic Usage
Section titled “Basic Usage”Return a single event directly from a handler method — synchronously or as a Task<T>:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Reactors;
[EventType]public record SideEffectsBookReserved(string Isbn);
[EventType]public record SideEffectsStockCheckRequested(string Isbn);
[EventType]public record StockDecreased(string Isbn, int Quantity);
public class WarehouseReactor : IReactor{ [OnceOnly] public StockDecreased BookReserved(SideEffectsBookReserved @event, EventContext context) => new(@event.Isbn, 1);
[OnceOnly] public async Task<StockDecreased> StockCheckRequestedAsync(SideEffectsStockCheckRequested @event, EventContext context) { var available = await FetchCurrentStockAsync(@event.Isbn); return new StockDecreased(@event.Isbn, available); }
Task<int> FetchCurrentStockAsync(string isbn) => Task.FromResult(0);}import io.cratis.chronicle.events.EventContextimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.observation.Reactor
@EventTypedata class SideEffectsBookReserved(val isbn: String)
@EventTypedata class SideEffectsStockCheckRequested(val isbn: String)
@EventTypedata class StockDecreased(val isbn: String, val quantity: Int)
@Reactorclass WarehouseReactor { // Return the event directly - it is appended to the event log using the EventSourceId from // the incoming event. fun bookReserved(event: SideEffectsBookReserved, context: EventContext) = StockDecreased(event.isbn, 1)
// A handler may suspend before returning its side-effect event. suspend fun stockCheckRequested(event: SideEffectsStockCheckRequested, context: EventContext): StockDecreased { val available = fetchCurrentStock(event.isbn) return StockDecreased(event.isbn, available) }
private suspend fun fetchCurrentStock(isbn: String): Int = 0}import io.cratis.chronicle.events.EventContext;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.observation.Reactor;
@EventTyperecord SideEffectsBookReserved(String isbn) {}
@EventTyperecord SideEffectsStockCheckRequested(String isbn) {}
@EventTyperecord StockDecreased(String isbn, int quantity) {}
@Reactorclass WarehouseReactor { // Return the event directly - it is appended to the event log using the EventSourceId from // the incoming event. StockDecreased bookReserved(SideEffectsBookReserved event, EventContext context) { return new StockDecreased(event.isbn(), 1); }
StockDecreased stockCheckRequested(SideEffectsStockCheckRequested event, EventContext context) { int available = fetchCurrentStock(event.isbn()); return new StockDecreased(event.isbn(), available); }
private int fetchCurrentStock(String isbn) { return 0; }}defmodule MyApp.Events.OrderConfirmationQueued do use Chronicle.Events.EventType, id: "order-confirmation-queued"
defstruct [:customer_id]end
defmodule MyApp.Reactors.OrderNotifier do use Chronicle.Reactors.Reactor
alias MyApp.Events.{OrderConfirmationQueued, OrderPlaced}
@handles OrderPlaced
@impl true def handle(%OrderPlaced{} = event, _context) do {:ok, %OrderConfirmationQueued{customer_id: event.customer_id}} endendimport { EventContext, eventType, onceOnly, reactor } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class SideEffectsBookReserved { @field(String) readonly isbn: string;
constructor(isbn: string = '') { this.isbn = isbn; }}
@eventType()class SideEffectsStockDecreased { @field(String) readonly isbn: string; @field(Number) readonly quantity: number;
constructor(isbn: string = '', quantity: number = 0) { this.isbn = isbn; this.quantity = quantity; }}
@reactor()class SideEffectsWarehouseReactor { // Returning an event from a handler appends it for you, targeting the triggering // event's own event source id, stream, and subject. @onceOnly() async sideEffectsBookReserved(event: SideEffectsBookReserved, context: EventContext): Promise<SideEffectsStockDecreased> { return new SideEffectsStockDecreased(event.isbn, 1); }}The returned event is appended to the event log using the EventSourceId from the incoming EventContext. No IEventLog injection required.
The handlers are marked [OnceOnly] because a returned event is a new fact: without it, replaying the reactor appends the same follow-up event again. Analyzer CHR0022 reports an event-returning handler that is not marked. [OnceOnly] does not cover a retry after the handler failed; see Delivery identity.
Multiple Side Effects
Section titled “Multiple Side Effects”In C#, return a sequence of known events — IEnumerable<TEvent>, TEvent[], List<TEvent>, or IReadOnlyList<TEvent> — to append several events in one handler call:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Reactors;
[EventType]public record MultipleSideEffectsBookReserved(string Isbn);
[EventType]public record MultipleStockDecreased(string Isbn, int Quantity);
[EventType]public record StockLow(string Isbn);
public class InventoryReactor : IReactor{ public IEnumerable<object> BookReserved(MultipleSideEffectsBookReserved @event, EventContext context) => [ new MultipleStockDecreased(@event.Isbn, 1), new StockLow(@event.Isbn), ];}import io.cratis.chronicle.events.EventContextimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.observation.Reactor
@EventTypedata class MultipleSideEffectsBookReserved(val isbn: String)
@EventTypedata class MultipleStockDecreased(val isbn: String, val quantity: Int)
@EventTypedata class StockLow(val isbn: String)
@Reactorclass InventoryReactor { // Return a List to append several events in one handler call. fun bookReserved(event: MultipleSideEffectsBookReserved, context: EventContext): List<Any> = listOf( MultipleStockDecreased(event.isbn, 1), StockLow(event.isbn) )}import io.cratis.chronicle.events.EventContext;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.observation.Reactor;
import java.util.List;
@EventTyperecord MultipleSideEffectsBookReserved(String isbn) {}
@EventTyperecord MultipleStockDecreased(String isbn, int quantity) {}
@EventTyperecord StockLow(String isbn) {}
@Reactorclass InventoryReactor { // Return a List to append several events in one handler call. List<Object> bookReserved(MultipleSideEffectsBookReserved event, EventContext context) { return List.of( new MultipleStockDecreased(event.isbn(), 1), new StockLow(event.isbn())); }}defmodule ReactorSideEffectsMultipleBookReserved do use Chronicle.Events.EventType, id: "reactor-side-effects-multiple-book-reserved"
defstruct [:isbn]end
defmodule ReactorSideEffectsMultipleStockDecreased do use Chronicle.Events.EventType, id: "reactor-side-effects-multiple-stock-decreased"
defstruct [:isbn, :quantity]end
defmodule ReactorSideEffectsMultipleStockLow do use Chronicle.Events.EventType, id: "reactor-side-effects-multiple-stock-low"
defstruct [:isbn]end
defmodule ReactorSideEffectsMultipleInventoryReactor do use Chronicle.Reactors.Reactor
alias ReactorSideEffectsMultipleBookReserved alias ReactorSideEffectsMultipleStockDecreased alias ReactorSideEffectsMultipleStockLow
@handles ReactorSideEffectsMultipleBookReserved
# Returning a list of bare event structs appends all of them, atomically, # to the triggering event's own event source id. @impl true def handle(%ReactorSideEffectsMultipleBookReserved{} = event, _context) do {:ok, [ %ReactorSideEffectsMultipleStockDecreased{isbn: event.isbn, quantity: 1}, %ReactorSideEffectsMultipleStockLow{isbn: event.isbn} ]} endendimport { EventContext, eventType, onceOnly, reactor } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class MultipleSideEffectsBookReserved { @field(String) readonly isbn: string;
constructor(isbn: string = '') { this.isbn = isbn; }}
@eventType()class MultipleSideEffectsStockDecreased { @field(String) readonly isbn: string; @field(Number) readonly quantity: number;
constructor(isbn: string = '', quantity: number = 0) { this.isbn = isbn; this.quantity = quantity; }}
@eventType()class MultipleSideEffectsStockLow { @field(String) readonly isbn: string;
constructor(isbn: string = '') { this.isbn = isbn; }}
@reactor()class MultipleSideEffectsInventoryReactor { // An array of events is appended together in one atomic AppendMany call - never // one append per item. @onceOnly() async multipleSideEffectsBookReserved(event: MultipleSideEffectsBookReserved, context: EventContext): Promise<object[]> { return [ new MultipleSideEffectsStockDecreased(event.isbn, 1), new MultipleSideEffectsStockLow(event.isbn) ]; }}Targeting a Specific Event Source Id
Section titled “Targeting a Specific Event Source Id”Everything above lands the returned event on the triggering event’s EventSourceId — optionally redirected once for the whole reactor via ICanProvideEventSourceId. That covers most reactors. But automation and translation reactors often need to write to a different entity than the one that triggered them — or to several at once.
Picture a library. A member reserves a book, and that reservation is a fact about the book:
using Cratis.Chronicle.Events;
public readonly record struct MemberId(string Value){ public static implicit operator EventSourceId(MemberId id) => new(id.Value);}
public readonly record struct Isbn(string Value){ public static implicit operator EventSourceId(Isbn id) => new(id.Value);}
[EventType]public record BookReserved(MemberId MemberId, Isbn Isbn);import io.cratis.chronicle.events.EventType
@EventTypedata class BookReserved(val memberId: String, val isbn: String)import io.cratis.chronicle.events.EventType;
@EventTyperecord BookReserved(String memberId, String isbn) {}defmodule ReactorSideEffectsSourceEventBookReserved do use Chronicle.Events.EventType, id: "reactor-side-effects-source-event-book-reserved"
# Event source ids are plain strings in Elixir — no typed-id wrapper needed. defstruct [:member_id, :isbn]endTypeScript does not support this workflow yet.When it happens you want to record activity on the member — a different event source altogether. Return an EventForEventSourceId and pick the target EventSourceId yourself:
using Cratis.Chronicle.Events;using Cratis.Chronicle.EventSequences;using Cratis.Chronicle.Reactors;
[EventType]public record MemberActivityRecorded(Isbn Isbn);
public class ReservationReactor : IReactor{ public EventForEventSourceId BookReserved(BookReserved @event, EventContext context) => new(@event.MemberId, new MemberActivityRecorded(@event.Isbn));}import io.cratis.chronicle.events.EventContextimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.eventSequences.EventForEventSourceIdimport io.cratis.chronicle.observation.Reactor
@EventTypedata class MemberActivityRecorded(val isbn: String)
@Reactorclass ReservationReactor { fun bookReserved(event: BookReserved, context: EventContext) = EventForEventSourceId(event.memberId, MemberActivityRecorded(event.isbn))}import io.cratis.chronicle.events.EventContext;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.eventSequences.EventForEventSourceId;import io.cratis.chronicle.observation.Reactor;
@EventTyperecord MemberActivityRecorded(String isbn) {}
@Reactorclass ReservationReactor { EventForEventSourceId bookReserved(BookReserved event, EventContext context) { return new EventForEventSourceId(event.memberId(), new MemberActivityRecorded(event.isbn())); }}defmodule ReactorSideEffectsSpecificBookReserved do use Chronicle.Events.EventType, id: "reactor-side-effects-specific-book-reserved"
defstruct [:member_id, :isbn]end
defmodule ReactorSideEffectsSpecificMemberActivityRecorded do use Chronicle.Events.EventType, id: "reactor-side-effects-specific-member-activity-recorded"
defstruct [:isbn]end
defmodule ReactorSideEffectsSpecificReservationReactor do use Chronicle.Reactors.Reactor
alias Chronicle.EventSequences.EventForEventSourceId alias ReactorSideEffectsSpecificBookReserved alias ReactorSideEffectsSpecificMemberActivityRecorded
@handles ReactorSideEffectsSpecificBookReserved
# MemberActivityRecorded is a fact about the member, not the book that # triggered the reactor — pick the target event source id explicitly by # returning an EventForEventSourceId instead of a bare event. @impl true def handle(%ReactorSideEffectsSpecificBookReserved{} = event, _context) do {:ok, %EventForEventSourceId{ event_source_id: event.member_id, event: %ReactorSideEffectsSpecificMemberActivityRecorded{isbn: event.isbn} }} endendimport { EventContext, EventForEventSourceId, eventType, onceOnly, reactor } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class SpecificSourceBookReserved { @field(String) readonly isbn: string; @field(String) readonly memberId: string;
constructor(isbn: string = '', memberId: string = '') { this.isbn = isbn; this.memberId = memberId; }}
@eventType()class SpecificSourceMemberActivityRecorded { @field(String) readonly isbn: string;
constructor(isbn: string = '') { this.isbn = isbn; }}
@reactor()class SpecificSourceReservationReactor { // Returning an EventForEventSourceId targets a different event source than the one // that triggered the reactor - here, the member's own stream rather than the book's. @onceOnly() async specificSourceBookReserved(event: SpecificSourceBookReserved, context: EventContext): Promise<EventForEventSourceId> { return { eventSourceId: event.memberId, event: new SpecificSourceMemberActivityRecorded(event.isbn) }; }}MemberActivityRecorded is appended to @event.MemberId, not to the book that triggered the reactor — so it shows up on the member’s stream, ready for a member-activity projection to pick up.
A single reservation usually ripples to more than one entity: the member gains activity, and the book loses stock. In C#, return IEnumerable<EventForEventSourceId>, EventForEventSourceId[], List<EventForEventSourceId>, or IReadOnlyList<EventForEventSourceId> to fan out to several event source ids in one go — they are appended together as a single transaction:
using Cratis.Chronicle.Events;using Cratis.Chronicle.EventSequences;using Cratis.Chronicle.Reactors;
[EventType]public record FanOutStockDecreased(Isbn Isbn, int Quantity);
public class ReservationFanOutReactor : IReactor{ public IEnumerable<EventForEventSourceId> BookReserved(BookReserved @event, EventContext context) => [ new(@event.MemberId, new MemberActivityRecorded(@event.Isbn)), new(@event.Isbn, new FanOutStockDecreased(@event.Isbn, 1)), ];}// Requires io.cratis:chronicle 6.5.0 or later.import io.cratis.chronicle.events.EventContextimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.eventSequences.EventForEventSourceIdimport io.cratis.chronicle.observation.Reactor
@EventTypedata class FanOutStockDecreased(val isbn: String, val quantity: Int)
@Reactorclass ReservationFanOutReactor { // The returned list is appended as one atomic batch, even across event sources. fun bookReserved(event: BookReserved, context: EventContext): List<EventForEventSourceId> = listOf( EventForEventSourceId(event.memberId, MemberActivityRecorded(event.isbn)), EventForEventSourceId(event.isbn, FanOutStockDecreased(event.isbn, 1)) )}// Requires io.cratis:chronicle 6.5.0 or later.import io.cratis.chronicle.events.EventContext;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.eventSequences.EventForEventSourceId;import io.cratis.chronicle.observation.Reactor;
import java.util.List;
@EventTyperecord FanOutStockDecreased(String isbn, int quantity) {}
@Reactorclass ReservationFanOutReactor { // The returned list is appended as one atomic batch, even across event sources. List<EventForEventSourceId> bookReserved(BookReserved event, EventContext context) { return List.of( new EventForEventSourceId(event.memberId(), new MemberActivityRecorded(event.isbn())), new EventForEventSourceId(event.isbn(), new FanOutStockDecreased(event.isbn(), 1))); }}defmodule ReactorSideEffectsFanOutBookReserved do use Chronicle.Events.EventType, id: "reactor-side-effects-fan-out-book-reserved"
defstruct [:member_id, :isbn]end
defmodule ReactorSideEffectsFanOutMemberActivityRecorded do use Chronicle.Events.EventType, id: "reactor-side-effects-fan-out-member-activity-recorded"
defstruct [:isbn]end
defmodule ReactorSideEffectsFanOutStockDecreased do use Chronicle.Events.EventType, id: "reactor-side-effects-fan-out-stock-decreased"
defstruct [:isbn, :quantity]end
defmodule ReactorSideEffectsFanOutReservationReactor do use Chronicle.Reactors.Reactor
alias Chronicle.EventSequences.EventForEventSourceId alias ReactorSideEffectsFanOutBookReserved alias ReactorSideEffectsFanOutMemberActivityRecorded alias ReactorSideEffectsFanOutStockDecreased
@handles ReactorSideEffectsFanOutBookReserved
# A reservation ripples to two different event sources — the member gains # activity, and the book loses stock. Returning a list of # EventForEventSourceId appends them together as one atomic transaction. @impl true def handle(%ReactorSideEffectsFanOutBookReserved{} = event, _context) do {:ok, [ %EventForEventSourceId{ event_source_id: event.member_id, event: %ReactorSideEffectsFanOutMemberActivityRecorded{isbn: event.isbn} }, %EventForEventSourceId{ event_source_id: event.isbn, event: %ReactorSideEffectsFanOutStockDecreased{isbn: event.isbn, quantity: 1} } ]} endendimport { EventContext, EventForEventSourceId, eventType, onceOnly, reactor } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class FanOutBookReserved { @field(String) readonly isbn: string; @field(String) readonly memberId: string;
constructor(isbn: string = '', memberId: string = '') { this.isbn = isbn; this.memberId = memberId; }}
@eventType()class FanOutMemberActivityRecorded { @field(String) readonly isbn: string;
constructor(isbn: string = '') { this.isbn = isbn; }}
@eventType()class FanOutStockDecreased { @field(String) readonly isbn: string; @field(Number) readonly quantity: number;
constructor(isbn: string = '', quantity: number = 0) { this.isbn = isbn; this.quantity = quantity; }}
@reactor()class ReservationFanOutReactor { @onceOnly() async fanOutBookReserved(event: FanOutBookReserved, context: EventContext): Promise<EventForEventSourceId[]> { return [ { eventSourceId: event.memberId, event: new FanOutMemberActivityRecorded(event.isbn) }, { eventSourceId: event.isbn, event: new FanOutStockDecreased(event.isbn, 1) } ]; }}Each EventForEventSourceId is self-describing: alongside the source id it carries the event stream type and id, source type, subject, occurred time, tags and causation. Set only the ones you need — the rest fall back to sensible defaults:
using Cratis.Chronicle.Events;using Cratis.Chronicle.EventSequences;using Cratis.Chronicle.Reactors;
public class ExplicitMetadataReactor : IReactor{ public EventForEventSourceId BookReserved(BookReserved @event, EventContext context) => new(@event.MemberId, new MemberActivityRecorded(@event.Isbn)) { EventStreamType = new EventStreamType("members"), Subject = new Subject(@event.MemberId.Value), };}import io.cratis.chronicle.events.EventContextimport io.cratis.chronicle.observation.Reactorimport io.cratis.chronicle.eventSequences.EventForEventSourceId
@Reactorclass ExplicitMetadataReactor { fun bookReserved(event: BookReserved, context: EventContext) = EventForEventSourceId( eventSourceId = event.memberId, event = MemberActivityRecorded(event.isbn), eventStreamType = "members", subject = event.memberId )}import io.cratis.chronicle.events.EventContext;import io.cratis.chronicle.eventSequences.EventForEventSourceId;import io.cratis.chronicle.observation.Reactor;
@Reactorclass ExplicitMetadataReactor { EventForEventSourceId bookReserved(BookReserved event, EventContext context) { return new EventForEventSourceId( event.memberId(), new MemberActivityRecorded(event.isbn()), "members", null, null, java.util.List.of(), null, event.memberId(), java.util.List.of()); }}defmodule ReactorSideEffectsExplicitBookReserved do use Chronicle.Events.EventType, id: "reactor-side-effects-explicit-book-reserved"
defstruct [:member_id, :isbn]end
defmodule ReactorSideEffectsExplicitMemberActivityRecorded do use Chronicle.Events.EventType, id: "reactor-side-effects-explicit-member-activity-recorded"
defstruct [:isbn]end
defmodule ReactorSideEffectsExplicitMetadataReactor do use Chronicle.Reactors.Reactor
alias Chronicle.EventSequences.EventForEventSourceId alias ReactorSideEffectsExplicitBookReserved alias ReactorSideEffectsExplicitMemberActivityRecorded
@handles ReactorSideEffectsExplicitBookReserved
# An EventForEventSourceId is self-describing: alongside the target event # source id it carries the stream type, subject, and every other append # option. Set only the fields you need — the rest fall back to defaults. @impl true def handle(%ReactorSideEffectsExplicitBookReserved{} = event, _context) do {:ok, %EventForEventSourceId{ event_source_id: event.member_id, event: %ReactorSideEffectsExplicitMemberActivityRecorded{isbn: event.isbn}, event_stream_type: "members", subject: event.member_id }} endendimport { EventContext, EventForEventSourceId, eventType, onceOnly, reactor } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class ExplicitMetadataBookReserved { @field(String) readonly isbn: string; @field(String) readonly memberId: string;
constructor(isbn: string = '', memberId: string = '') { this.isbn = isbn; this.memberId = memberId; }}
@eventType()class ExplicitMetadataMemberActivityRecorded { @field(String) readonly isbn: string;
constructor(isbn: string = '') { this.isbn = isbn; }}
@reactor()class ExplicitMetadataReactor { @onceOnly() async explicitMetadataBookReserved(event: ExplicitMetadataBookReserved, context: EventContext): Promise<EventForEventSourceId> { return { eventSourceId: event.memberId, event: new ExplicitMetadataMemberActivityRecorded(event.isbn), eventStreamType: 'members', subject: event.memberId }; }}Two modes: a bare event uses the reactor’s resolved metadata — its
[EventStreamType]/[EventSourceType]attributes andICanProvide*interfaces. AnEventForEventSourceIdis the explicit mode: it is self-describing and uses only the values on it, so the reactor’s metadata does not apply (an unset field takes the append default such asEventStreamType.All, and an omittedSubjectis resolved from the event). Reach for a bare event when you want “use my reactor’s config”, and anEventForEventSourceIdwhen you want “I’ll specify exactly where and how”.
In C#, you can also mix the two in a single IEnumerable<object>, object[], List<object>, or IReadOnlyList<object> return — each bare event uses the reactor’s resolved metadata and the triggering event source id, while each EventForEventSourceId keeps its own. They are appended together as one transaction:
using Cratis.Chronicle.Events;using Cratis.Chronicle.EventSequences;using Cratis.Chronicle.Reactors;
[EventType]public record ActivityLogged(Isbn Isbn);
public class MixedSideEffectsReactor : IReactor{ public IEnumerable<object> BookReserved(BookReserved @event, EventContext context) => [ new ActivityLogged(@event.Isbn), new EventForEventSourceId(@event.MemberId, new MemberActivityRecorded(@event.Isbn)), ];}// Requires io.cratis:chronicle 6.5.0 or later.import io.cratis.chronicle.events.EventContextimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.eventSequences.EventForEventSourceIdimport io.cratis.chronicle.observation.Reactor
@EventTypedata class ActivityLogged(val isbn: String)
@Reactorclass MixedSideEffectsReactor { // A bare event uses the triggering event's EventSourceId; an EventForEventSourceId keeps its // own. The returned list is appended as one atomic batch, even across event sources. fun bookReserved(event: BookReserved, context: EventContext): List<Any> = listOf( ActivityLogged(event.isbn), EventForEventSourceId(event.memberId, MemberActivityRecorded(event.isbn)) )}// Requires io.cratis:chronicle 6.5.0 or later.import io.cratis.chronicle.events.EventContext;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.eventSequences.EventForEventSourceId;import io.cratis.chronicle.observation.Reactor;
import java.util.List;
@EventTyperecord ActivityLogged(String isbn) {}
@Reactorclass MixedSideEffectsReactor { // A bare event uses the triggering event's EventSourceId; an EventForEventSourceId keeps its // own. The returned list is appended as one atomic batch, even across event sources. List<Object> bookReserved(BookReserved event, EventContext context) { return List.of( new ActivityLogged(event.isbn()), new EventForEventSourceId(event.memberId(), new MemberActivityRecorded(event.isbn()))); }}defmodule ReactorSideEffectsMixedBookReserved do use Chronicle.Events.EventType, id: "reactor-side-effects-mixed-book-reserved"
defstruct [:member_id, :isbn]end
defmodule ReactorSideEffectsMixedActivityLogged do use Chronicle.Events.EventType, id: "reactor-side-effects-mixed-activity-logged"
defstruct [:isbn]end
defmodule ReactorSideEffectsMixedMemberActivityRecorded do use Chronicle.Events.EventType, id: "reactor-side-effects-mixed-member-activity-recorded"
defstruct [:isbn]end
defmodule ReactorSideEffectsMixedReservationReactor do use Chronicle.Reactors.Reactor
alias Chronicle.EventSequences.EventForEventSourceId alias ReactorSideEffectsMixedBookReserved alias ReactorSideEffectsMixedActivityLogged alias ReactorSideEffectsMixedMemberActivityRecorded
@handles ReactorSideEffectsMixedBookReserved
# A bare event uses the triggering event's own event source id; an # EventForEventSourceId keeps its own explicit target. Mixing both in one # list still appends them together as a single transaction. @impl true def handle(%ReactorSideEffectsMixedBookReserved{} = event, _context) do {:ok, [ %ReactorSideEffectsMixedActivityLogged{isbn: event.isbn}, %EventForEventSourceId{ event_source_id: event.member_id, event: %ReactorSideEffectsMixedMemberActivityRecorded{isbn: event.isbn} } ]} endendimport { EventContext, EventForEventSourceId, eventType, onceOnly, reactor } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class MixedBookReserved { @field(String) readonly isbn: string; @field(String) readonly memberId: string;
constructor(isbn: string = '', memberId: string = '') { this.isbn = isbn; this.memberId = memberId; }}
@eventType()class MixedActivityLogged { @field(String) readonly isbn: string;
constructor(isbn: string = '') { this.isbn = isbn; }}
@eventType()class MixedMemberActivityRecorded { @field(String) readonly isbn: string;
constructor(isbn: string = '') { this.isbn = isbn; }}
@reactor()class MixedSideEffectsReactor { // A bare event uses the triggering event's own target; an EventForEventSourceId // entry keeps its own explicit target - both can be returned together, in one // atomic AppendMany call. @onceOnly() async mixedBookReserved(event: MixedBookReserved, context: EventContext): Promise<Array<object | EventForEventSourceId>> { return [ new MixedActivityLogged(event.isbn), { eventSourceId: event.memberId, event: new MixedMemberActivityRecorded(event.isbn) } ]; }}Add concurrency scopes to returned events
Section titled “Add concurrency scopes to returned events”Returning events is convenient, but sometimes the decision is only valid against the exact event-log state the reactor read. Return EventsWithConcurrencyScopes to submit an ordered list of EventForEventSourceId values and its concurrency scopes through one AppendMany call:
using Cratis.Chronicle.Events;using Cratis.Chronicle.EventSequences;using Cratis.Chronicle.EventSequences.Concurrency;using Cratis.Chronicle.Reactors;
[EventType]public record ReservationConfirmed(Isbn Isbn);
public class ReservationConfirmationReactor : IReactor{ public EventsWithConcurrencyScopes BookReserved(BookReserved @event, EventContext context) => new( [new EventForEventSourceId(context.EventSourceId, new ReservationConfirmed(@event.Isbn))], new Dictionary<EventSourceId, ConcurrencyScope> { [context.EventSourceId] = new( context.SequenceNumber, EventSourceId: context.EventSourceId), });}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule ReactorSideEffectsConcurrencyBookReserved do use Chronicle.Events.EventType, id: "reactor-side-effects-concurrency-book-reserved"
defstruct [:isbn]end
defmodule ReactorSideEffectsConcurrencyReservationConfirmed do use Chronicle.Events.EventType, id: "reactor-side-effects-concurrency-reservation-confirmed"
defstruct [:isbn]end
defmodule ReactorSideEffectsConcurrencyReservationReactor do use Chronicle.Reactors.Reactor
alias Chronicle.EventSequences.EventForEventSourceId alias Chronicle.Events.ConcurrencyScope alias ReactorSideEffectsConcurrencyBookReserved alias ReactorSideEffectsConcurrencyReservationConfirmed
@handles ReactorSideEffectsConcurrencyBookReserved
# Only append if no later event for the triggering source has appeared # since this one was read — set concurrency_scope on the # EventForEventSourceId to the sequence number the reactor observed. @impl true def handle(%ReactorSideEffectsConcurrencyBookReserved{} = event, context) do {:ok, %EventForEventSourceId{ event_source_id: Map.get(context, :event_source_id), event: %ReactorSideEffectsConcurrencyReservationConfirmed{isbn: event.isbn}, concurrency_scope: ConcurrencyScope.for_event_source(Map.get(context, :sequence_number)) }} endendTypeScript does not support this workflow yet.Here the reactor appends only if no later event for the triggering source has appeared since BookReserved. Chronicle materializes the events and copies the scope dictionary when you create the result, then submits both together. A failed constraint, concurrency check, or append fails the reactor partition; Chronicle never splits the return into partial appends.
A scope entry with ConcurrencyScope.NotSet, or a missing entry for an event source id in the batch, asks the configured concurrency strategy to resolve that source’s scope. Use ConcurrencyScope.None when you explicitly want no check for a key. Chronicle passes every other scope through unchanged.
The event list preserves the order you supplied to the append operation. That does not impose a global processing order on observers: events targeting different source partitions can be observed independently. The scope dictionary also holds one scope per EventSourceId key; it cannot represent multiple independent scopes under the same key.
Duplicate protection for returned events
Section titled “Duplicate protection for returned events”In C#, the built-in result handlers append each returned event with an idempotency key that Chronicle derives from the delivery identity, the reactor’s effect epoch and the event’s position in the returned value. When the same delivery comes back, the kernel recognizes the keys and does not append the events a second time. This covers redelivery after a failed partition recovers, catch-up and replay: a returned event lands once per delivery.
What this does not cover is anything outside the event log. A payment call, an email or a webhook made inside the handler still runs again on every redelivery. Guard those with a receipt keyed by ReactorDelivery, as described in Delivery identity.
- A key you set yourself is kept. If an
EventForEventSourceIdalready carries anIdempotencyKey, Chronicle leaves it alone and keys only the events that have none. Usedelivery.IdempotencyKeyFor("name")to derive a stable key for a named effect. - Older kernels append unkeyed. Against a kernel that does not advertise idempotent appends, returned events are appended without keys and Chronicle logs one warning per reactor. A key you set yourself on such a kernel throws
IdempotentAppendsNotSupported, because silently dropping it would remove the protection you asked for. - The in-process testing package advertises the capability.
ReactorScenariorecords returned events by default instead of appending them. When you give it the real side-effect handlers,Cratis.Chronicle.Testingappends them with keys, like a current kernel does, so redelivering an occurrence does not append the events again. - Changed output is refused, not overwritten. If the handler returns something different for a delivery that was already appended, the kernel reports a conflict and the partition fails. The failure message names the remedy: raise the effect epoch or mark the handler
[OnceOnly]. - Build returned events from stable inputs. Derive the returned events from the triggering event and from state that does not change between deliveries. A payload carrying a fresh
Guid, the current time or the result of a lookup that has since changed counts as changed output when the delivery comes back, so the partition fails instead of appending a duplicate. Take such values from the triggering event, or derive them from the delivery identity.
Reactor-Level Metadata Resolution
Section titled “Reactor-Level Metadata Resolution”Set metadata once on the reactor type so every returned event inherits it automatically.
ICanProvideEventSourceId
Section titled “ICanProvideEventSourceId”Implement this interface to supply a custom EventSourceId for all side-effect events from this reactor:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Reactors;
public class WarehouseEventSourceReactor(string warehouseId) : IReactor, ICanProvideEventSourceId{ public EventSourceId GetEventSourceId() => warehouseId;
[OnceOnly] public StockDecreased BookReserved(SideEffectsBookReserved @event, EventContext context) => new(@event.Isbn, 1);}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.ICanProvideSubject
Section titled “ICanProvideSubject”Implement this interface to attach a Subject (e.g. a user or principal) to appended events:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Reactors;
public class OrderReactor(string userId) : IReactor, ICanProvideSubject{ public Subject GetSubject() => new(userId);}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.ICanProvideEventStreamId
Section titled “ICanProvideEventStreamId”Implement this interface to specify a runtime EventStreamId:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Reactors;
public class TenantReactor(string tenantId) : IReactor, ICanProvideEventStreamId{ public EventStreamId GetEventStreamId() => tenantId;}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.[EventStreamType] and [EventSourceType] Attributes
Section titled “[EventStreamType] and [EventSourceType] Attributes”Apply these attributes to the reactor class for a compile-time stream or source type:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Reactors;
[EventStreamType("warehouse")][EventSourceType("product")]public class WarehouseMetadataReactor : IReactor{ [OnceOnly] public StockDecreased BookReserved(SideEffectsBookReserved @event, EventContext context) => new(@event.Isbn, 1);}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.Priority Order
Section titled “Priority Order”| Metadata | Priority |
|---|---|
EventSourceId | ICanProvideEventSourceId -> eventContext.EventSourceId |
EventStreamId | ICanProvideEventStreamId -> [EventStreamId] attribute -> null |
EventStreamType | [EventStreamType] attribute -> null |
EventSourceType | [EventSourceType] attribute -> null |
Subject | ICanProvideSubject -> null |
Custom Return Type Handlers
Section titled “Custom Return Type Handlers”Extend the pipeline by registering a custom IReactorSideEffectHandler:
using Cratis.Chronicle;using Cratis.Chronicle.Reactors.SideEffects;using Cratis.Monads;using Microsoft.Extensions.DependencyInjection;
public record MySpecialResult;
public class MyHandler : IReactorSideEffectHandler{ public bool CanHandleReturnType(Type type) => type == typeof(MySpecialResult);
public bool CanHandle(ReactorContext reactorContext, object value) => value is MySpecialResult;
public bool CanHandle(ReactorContext reactorContext, IEventStore eventStore, object value) => CanHandle(reactorContext, value);
public Task<Result<ReactorSideEffectFailure>> Handle( ReactorContext reactorContext, IEventStore eventStore, object value) { return Task.FromResult(Result.Success<ReactorSideEffectFailure>()); }}
public static class ReactorSideEffectRegistration{ public static void Add(IServiceCollection services) { services.AddSingleton<IReactorSideEffectHandler, MyHandler>(); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.Chronicle registers its built-in handlers and reactor metadata providers when you call AddCratisChronicle, including in standalone hosts. Register custom handlers under IReactorSideEffectHandler or their concrete type in the host’s service collection. Handlers discovered from other loaded libraries only participate when registered; merely loading an assembly does not activate its integration.
To return your custom side effect synchronously, implement CanHandleReturnType(Type) on the handler to claim its declared type. Mark the return type with [ReactorSideEffect] from Cratis.Chronicle.Reactors.SideEffects so analyzer CHR0054 accepts it. The marker may be applied to classes, structs, or interfaces; types deriving from a marked base class or implementing a marked interface are also accepted. It only informs the analyzer: it does not register a handler or make the type supported at runtime.
Existing handlers default to false and can still handle asynchronous Task<T> results without a marker. A handler can also claim a declared IEnumerable<T> return type; it must process the collection at invocation time. Arrays and IEnumerable<T> implementations of marked types are accepted by the analyzer, but the handler must claim the declared collection type, not only its elements. Returning a type that no handler claims synchronously is rejected during registration.
Supported Return Types
Section titled “Supported Return Types”In C#, the IEnumerable<T> entries below also accept T[], List<T>, and other types implementing IEnumerable<T> (such as IReadOnlyList<T>), including when wrapped in Task<TSequence>. Multidimensional arrays are not supported.
| Return type | Handler invoked |
|---|---|
TEvent | EventResultHandler — appends single event to the triggering/reactor event source id |
Task<TEvent> | EventResultHandler |
IEnumerable<TEvent> | EventsResultHandler — appends each event |
Task<IEnumerable<TEvent>> | EventsResultHandler |
EventForEventSourceId | EventForEventSourceIdResultHandler — appends to the event source id carried by the value |
Task<EventForEventSourceId> | EventForEventSourceIdResultHandler |
IEnumerable<EventForEventSourceId> | EventsForEventSourceIdResultHandler — appends all as one transaction |
Task<IEnumerable<EventForEventSourceId>> | EventsForEventSourceIdResultHandler |
EventsWithConcurrencyScopes | EventsWithConcurrencyScopesResultHandler — appends its ordered events and scopes as one transaction |
Task<EventsWithConcurrencyScopes> | EventsWithConcurrencyScopesResultHandler |
IEnumerable<object> mixing events and EventForEventSourceId | MixedSideEffectsResultHandler — bare events use reactor metadata, each EventForEventSourceId keeps its own; all appended as one transaction |
Custom T or IEnumerable<T> claimed by a registered handler | The claiming IReactorSideEffectHandler processes the return value |
void / Task | No side effects appended |
Error handling
Section titled “Error handling”If a side-effect append fails, Chronicle marks the reactor partition as failed. A non-null return value that no side-effect handler claims also fails the partition instead of being discarded and acknowledged. Register a handler for the returned type (or return a supported event), then retry or rewind the observer to continue processing.