Skip to content

Reactors

Reactors are documented in the shared Chronicle docs with synchronized examples for C#, Kotlin, Java, Elixir, and TypeScript.

Use the TypeScript get started page for package installation and connection setup.

By default, Chronicle calls the method whose name is the camelCase name of a registered event class: authorRegistered(event) handles AuthorRegistered. No method decorator is needed for that convention.

To name the method after what it does, use @handles(AuthorRegistered) instead:

import { field } from '@cratis/fundamentals';
import { eventType, handles, reactor } from '@cratis/chronicle';
@eventType()
class AuthorRegistered {
@field(String) name = '';
}
@reactor()
class AuthorReactor {
@handles(AuthorRegistered)
notify(event: AuthorRegistered): void {
console.log(`Author registered: ${event.name}`);
}
}

With these artifacts registered in your event store, an AuthorRegistered event calls notify. The decorator identifies the event constructor; TypeScript erases the parameter type at runtime. @handles supports legacy (experimentalDecorators) and standard TC39 decorators, and is also exported from @cratis/chronicle/reactors and @cratis/chronicle/reducers.

  • An explicit event type replaces the method-name convention for that method, even if its name matches a different event class.
  • Registration fails if the constructor passed to @handles is not a registered @eventType, or if two methods handle the same event type. That includes one explicit handler alongside a conventional handler. Keep one live handler per event; rename a conventional method only if it should be a helper.
  • Undecorated methods that match no registered event class remain ordinary helpers. Chronicle cannot tell a misspelled handler from a helper; use @handles when you want an explicit declaration checked at registration.
  • Use a public, string-named instance method, not a static method, private #method, accessor, symbol method, or function-valued field. Each method accepts one @handles declaration. Inherited handlers work. An override of a method carrying @handles must declare its own @handles, even when keeping the same event type. Otherwise registration fails with Override '<method>' on '<Derived>' hides @handles(<EventType>) declared on '<Base>'; redecorate the override. Chronicle validates methods carrying @handles when each instance is created, including instances returned by an activator and ReactorScenario instances. Binding the declared method in the constructor is supported; other own properties, including arrow-field overrides, are rejected. Use a method decorated with @handles for an override. Conventional and @replay handlers retain their existing instance-property behavior.
  • @onceOnly() works with @handles in either decorator order. Without a replay-specific handler, the explicit handler runs during replay too unless marked once-only. For separate replay behavior, use @replay(AuthorRegistered) on another method; do not combine @handles and @replay on one method.

ReactorScenario from @cratis/chronicle/testing uses the same handler selection and rejects invalid prototype mappings before constructing the reactor. It also validates instance properties hiding @handles methods after construction, allowing methods bound with Function.prototype.bind in a constructor (including bound again in a subclass constructor). Reducers also support @handles, without changing their handler arguments or returned state.

To receive only events from a particular source or stream type, put @eventSourceType('customer') or @eventStreamType('payments') on the reactor class. Import both decorators from @cratis/chronicle or @cratis/chronicle/events; they support legacy and standard decorators. Derived reactors inherit these filters unless they declare their own value.

DecoratorDefaultEffect
@eventSourceType(value: string)'' (unspecified)Restricts delivery to the given source type; '' removes that restriction.
@eventStreamType(value: string)'All'Restricts delivery to the given stream type; 'All' removes that restriction.

The kernel combines source type, stream type, and @filterEventsByTag restrictions with AND. Multiple filter tags match any of those tags. @tag labels the observer and does not restrict delivery. These decorators belong on observers and do not filter projections.

On a reactor, they also supply append metadata for bare returned events, matching .NET [EventSourceType] and [EventStreamType]: a non-empty source type sets the returned event’s source type, and a non-empty declared stream type overrides the triggering stream type. Without a stream decorator, bare returns keep the triggering stream type; explicitly declaring 'All' also supplies that value to returned events. Each returned EventForEventSourceId keeps its own metadata, including omitted or empty type values, rather than inheriting the reactor’s types. Explicit calls to services.eventStore.eventLog.append do not inherit these decorators; set sourceType and streamType in append options to route those events.

ReactorScenario rejects non-default source or stream filters with UnsupportedReactorOperation rather than silently delivering unfiltered events. Use a kernel-backed specification for filtered delivery.

The optional third argument, ReactorServices, gives the owning observation’s eventStore, its readModels (services.readModels === services.eventStore.readModels), and a disconnect/shutdown signal. It is the consuming store and namespace, including for imported events, not an application-wide default or the upstream event’s provenance. Existing one- and two-argument methods work unchanged; rest-parameter handlers now see this additional argument.

To append explicitly, await services.eventStore.eventLog.append(context.eventSourceId, event) and check result.isSuccess; a rejected append does not throw automatically. Throw if it failed so the observation is not acknowledged. To read state, use services.readModels.findInstanceById(Model, context.eventSourceId) and handle null. Read models are eventually consistent: a read may lag the triggering event or be absent, including during replay. There is no automatic historical snapshot at the event’s sequence number. If a missing model prevents your side effect, throw rather than acknowledging lost work; make retries safe.

A reactor handler method can return a side effect instead of only observing: a single event, an array of events, a single EventForEventSourceId (to target an event source other than the one that triggered the reactor), an array of those, or a mix. Whatever is returned is appended in one atomic appendMany call once the handler completes — a bare event uses the triggering event’s own event source id, stream id, and subject, with source and stream types resolved as described above; an EventForEventSourceId entry keeps its own target. If the side-effect append fails, the reactor’s partition is marked Failed, the same as if the handler itself had thrown.

For application-owned return types, pass reactorResultHandler to ChronicleOptions.fromConnectionString(connectionString, { reactorResultHandler }) (or development({ reactorResultHandler })). The callback receives the returned value, triggering EventContext, reactor class, event store name, and namespace. Return true only after handling the entire result; return false to let Chronicle append its recognized event returns. Throw on failure so the partition fails instead of acknowledging a lost side effect. The hook is installed before reactor observations begin. It does not provide a transaction across commands and events; handle mixed returns deliberately.

The SDK normally calls the zero-argument constructor once per observation stream and reuses that instance across deliveries and replay notifications. Set artifactActivator on ChronicleOptions.development({ artifactActivator }) (or fromConnectionString) to resolve constructor dependencies instead. With an activator, each delivered event batch gets one lease and one shared instance; replay lifecycle notifications get separate leases. No lease is made for a message without events or a replay transition. When every event in a batch is skipped, no event-batch lease is made (though a replay transition in that message still gets its separate notification lease). A reconnect creates a new observation, and its context identifies the exact store/namespace and signal for that generation.

import { ArtifactDelivery, ArtifactKind, ChronicleOptions, type ClientArtifactsActivator } from '@cratis/chronicle';
const artifactActivator: ClientArtifactsActivator = async (type, context) => {
// Resolve `type` through your container in a delivery-owned scope here.
// This simple example works only for classes without constructor dependencies.
const instance = new type();
if (context.kind === ArtifactKind.Reactor && context.delivery === ArtifactDelivery.Events) {
console.log(context.artifactId, context.eventStore.namespace.value, context.eventContext.sequenceNumber);
}
return { instance };
};
const options = ChronicleOptions.development({ artifactActivator });

The context contains kind, artifactId, eventStore, readModels, eventSequenceId, partition, and signal. Its delivery discriminant provides eventContext (the first handled event, not a promise that all events have the same context) or replayState. The activator may return { instance, run, complete, dispose }: run(callback, invocation) optionally enters your scope for each handler invocation and its returned side-effect dispatch. The SDK always supplies invocation: for events, it has delivery: ArtifactDelivery.Events, the current eventContext, and methodName; for replay notifications it has delivery: ArtifactDelivery.ReplayNotification and replayState. Existing run(callback) implementations can ignore the second argument.

complete() is awaited once per created lease after processing (even when a handler fails), before the observation result is acknowledged. If it rejects, the partition fails with ArtifactCompletionFailed; any processing failure is also reported. A completion failure does not advance the batch’s checkpoint or publish its tentative reducer state, but already performed external effects are not rolled back and may repeat on retry. Make them idempotent. dispose() still runs unconditionally after completion and is awaited once; its errors are logged, not treated as delivery failures. Only the lease is disposed by the SDK, not a container-owned instance separately. Without an activator, no new ambient metadata/execution boundary is installed. Keep batch-local mutable instance state out of cross-batch assumptions, and stop work cooperatively when signal aborts; arbitrary application promises cannot be forcibly canceled.

A @reactor/@reducer-decorated class instance can also optionally implement ICanBeNotifiedWhenReplay (beginReplay/endReplay) and/or ICanBeNotifiedWhenPartitionReplayed (beginReplayPartition/endReplayPartition, both given the partition) to be notified when a replay begins and ends, at the full-observer or per-partition granularity.

Reactors are replayable by default. Put @onceOnly() on a handler to skip that handler for replayed events, or on the reactor class to register the entire reactor as non-replayable so the kernel does not replay it.

import { eventType, reactor, onceOnly, replay } from '@cratis/chronicle';
@eventType()
class OrderPlaced {
constructor(readonly orderId: string = '') {}
}
@reactor()
class OrderReactor {
@onceOnly()
async orderPlaced(event: OrderPlaced): Promise<void> {
// Notify the customer on live delivery; never notify again during replay.
}
@replay()
async replayOrderPlaced(event: OrderPlaced): Promise<void> {
// Rebuild replay-specific state instead of running orderPlaced.
}
}

Upgrade caution: Existing reactors now accept kernel replays, including explicit replays and automatic replays such as revision/redaction rewinds or definition-change replays when enabled. Mark side-effecting reactors with class-level @onceOnly() if none of their handlers should replay; use method-level @onceOnly() when only particular handlers have side effects. Review existing reactors before upgrading to avoid repeating notifications, external calls, or returned events.

@replay() uses the replay<EventClassName> method naming convention; @replay(OrderPlaced) also accepts an explicit event type when a different name is useful. A replay-only handler subscribes to its event type, but does not run during live delivery. Without a replay-specific handler, the ordinary event handler (camelCase or @handles) runs for replayed events unless that method has @onceOnly(). When both are present, only the replay handler runs during replay; method-level @onceOnly() on the ordinary handler does not prevent it. Both decorators support legacy and standard TypeScript decorator syntax.

These policies apply to the per-event observation state sent by the kernel, not the separate begin/end replay lifecycle notifications. A message with both a notification and events notifies first, then handles the events; a failed notification fails the partition instead. @onceOnly() excludes replay, not duplicates or retries. Explicit writes and returned appends are not automatically one transaction with the triggering delivery; make effects idempotent, including under concurrent retries. Replay is not an append sandbox: unguarded explicit or returned writes can happen again during replay.

A new reactor catches up from the very beginning of the event sequence, as ordinary observation, so one whose handlers have side effects acts on every historical event the moment it is deployed. @onceOnly() does not prevent that, because it only excludes replay. Put @startAtTail() on the reactor class and the first time Chronicle sees it, the reactor begins at the tail of the event sequence and observes only what is appended afterwards.

import { reactor, startAtTail } from '@cratis/chronicle';
@reactor()
@startAtTail()
class SendWelcomeEmails {
welcomeEmailRequested(event: WelcomeEmailRequested): void {
// Runs for events appended after this reactor was first registered.
}
}

The marker only affects the first registration. A reactor that already has a position keeps it, so it never skips events after a restart. Renaming the reactor or moving it changes its default id and makes it a new reactor, which starts at the tail again; give @reactor() an explicit id to keep the old position. An explicit replay still replays from the beginning. Both the Chronicle server and this client must support it: a server that predates it ignores the request and the reactor catches up from the beginning. The marker is not inherited by subclasses.