Skip to content

Subscriptions & events

Actors don't call each other. When something happens that other actors (or a UI) might care about, an actor emits a domain event — a fact — and anyone subscribed receives it. This is the actor-model way to coordinate without coupling: the producer doesn't know who is listening.

Emitting events

A handler broadcasts events with the emit field of its result:

ts
return {
  context: { count },
  emit: [{ type: 'COUNT_CHANGED', count }],
};

emit is a 1-to-many broadcast, distinct from reply (the 1-to-1 response to an ask). Emitted events are delivered straight into each subscriber's mailbox as ordinary messages — "events are just messages," the same model as Erlang/OTP and Akka.

Machine-backed actors can emit from the machine itself: an XState v5 emit(...) action is bridged onto the same stream, so a withMachine actor announces domain events with no handler.

ts
// inside the machine — emit a domain event as a transition action
MERGE: { target: 'resolved', actions: emit(() => ({ type: 'OUTCOME_RESOLVED', outcome: 'merged' })) }

Reacting to another actor's events

An actor becomes a subscriber by being wired to a publisher. Dynamic wiring is explicit:

ts
// Deliver `compare`'s OUTCOME_RESOLVED events into the pipeline actor.
await system.subscribe(compareRef, {
  subscriber: pipelineRef,
  events: ['OUTCOME_RESOLVED'],
});

For imperative fan-out, pass a batch of subscriber refs. The returned unsubscribe removes every subscriber registered by that call:

ts
await system.subscribe(compareRef, {
  subscribers: [pipelineRef, actorSystemRef],
  events: ['OUTCOME_RESOLVED'],
});

The subscriber then handles OUTCOME_RESOLVED like any other message and decides what to do — advance a pipeline, activate a role, update a projection. Because the producer only emits, it stays ignorant of its consumers (choreography, not orchestration).

Orchestration vs. choreography

  • Choreography — each consumer subscribes to events and decides its own reaction. Maximally decoupled; the reaction logic lives with the consumers.
  • Orchestration — a dedicated coordinator actor subscribes to events and tells others what to do. Centralizes a cross-cutting rule at the cost of a coordinator that knows the participants.

Both are built from the same emit + subscription primitives. Reach for a coordinator only when a rule genuinely spans several actors.

Declarative subscriptions

Wiring subscriptions imperatively at startup is easy to lose track of — and in-memory subscriptions don't survive a full runtime restart. Declare them in the topology instead: the runtime wires them on start and tears them down on stop, so the wiring is durable (re-established on every start) and type-checked.

ts
defineActorWebTopology({
  actors: { compare, pipeline, actorSystem },
  subscriptions: [
    { from: 'compare', to: ['actorSystem', 'pipeline'], events: ['OUTCOME_RESOLVED'] },
  ],
});

from/to are actor keys (to accepts one key or an array for fan-out); events filters by type (omit for all events from from). This is the preferred path; system.subscribe(...) remains the imperative escape hatch for dynamic wiring.

Cross-node subscriptions

When from and to live on different nodes, the runtime forwards emitted events over the transport: the publisher node delivers each matching event to the subscriber actor on the peer node. The subscriber node establishes the edge on start and re-establishes it on reconnect, so the wiring stays durable across restarts. Delivery is at-most-once — see Transport delivery guarantees for what happens to events emitted before the handshake or while a peer is down. A cross-node subscription requires a configured transport; without one the runtime fails loudly at start rather than silently dropping events.

See also