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 StockDecreased(string Isbn, int Quantity);
public class WarehouseReactor : IReactor{ public StockDecreased BookReserved(SideEffectsBookReserved @event, EventContext context) => new(@event.Isbn, 1);
public async Task<StockDecreased> BookReservedAsync(SideEffectsBookReserved @event, EventContext context) { var available = await FetchCurrentStockAsync(@event.Isbn); return new StockDecreased(@event.Isbn, available); }
Task<int> FetchCurrentStockAsync(string isbn) => Task.FromResult(0);}The returned event is appended to the event log using the EventSourceId from the incoming EventContext. No IEventLog injection required.
Multiple Side Effects
Section titled “Multiple Side Effects”Return IEnumerable<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), ];}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);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));}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. Return IEnumerable<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)), ];}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), };}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”.
You can also mix the two in a single IEnumerable<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)), ];}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;
public StockDecreased BookReserved(SideEffectsBookReserved @event, EventContext context) => new(@event.Isbn, 1);}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);}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;}[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{ public StockDecreased BookReserved(SideEffectsBookReserved @event, EventContext context) => new(@event.Isbn, 1);}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 CanHandle(ReactorContext reactorContext, object value) => value is MySpecialResult;
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>(); }}Supported Return Types
Section titled “Supported Return Types”| 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 |
IEnumerable<object> mixing events and EventForEventSourceId | MixedSideEffectsResultHandler — bare events use reactor metadata, each EventForEventSourceId keeps its own; all appended as one transaction |
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. Fix the underlying cause and retry or rewind the observer to continue processing.