React to another feature's events
Feature A needs to react to a write that happens in feature B — “when an incident is created, send an email”, “when a user signs up, populate their default workspace”. The two features stay decoupled: A never imports from B’s source.
Prerequisites
Section titled “Prerequisites”- You’ve read Events and projections so the
default
<entity>.createdevent is familiar. - A is a feature in your codebase; B is either yours or a bundled feature.
The code
Section titled “The code”Two paths, depending on whether B has declared a domain event or you want to attach to its CRUD lifecycle.
Path 1 — react to a write handler. B exports its handler refs; A subscribes a job to one:
// In feature A's body:import { handlers as incidentHandlers } from "../incidents/feature";
r.requires("incidents");
r.job( "send-resolution-email", { trigger: { on: incidentHandlers.resolve }, runIn: "worker" }, async (payload, ctx) => { const incident = await ctx.query("incidents:detail", { id: payload["id"] as string }); await ctx.delivery.send({ to: incident.reporterId, template: "incident-resolved", data: { title: incident.title, resolution: payload["resolution"] }, }); },);r.job is positional: r.job(name, options, handler). The handler
receives the trigger payload first, ctx second — for a
write-handler trigger, payload is whatever was passed to
ctx.write("incidents:resolve", …) (matches incidents:resolve’s
schema). Delivery is at-least-once and asynchronous, after the
originating transaction commits — make the handler idempotent.
Path 2 — react to a CRUD lifecycle. B has no domain event for the moment you need; you attach to its entity directly:
// In feature A's body:r.requires("incidents");
r.hook("postSave", "incident", async (ctx, { id, data, isNew }) => { if (!isNew) return; await ctx.delivery.send({ to: data.reporterId, template: "incident-acknowledged", data: { title: data.title, link: `/incidents/${id}` }, });}, { phase: HookPhases.afterCommit });The hook runs after the transaction commits — appropriate for
external side effects that must not roll back. For database writes
that need to stay atomic with the original change, use
HookPhases.inTransaction instead.
Why not just import the table
Section titled “Why not just import the table”A direct import — import { incidentTable } from "../incidents/db" —
would bypass the field-access rules, the tenant filter (if A’s hook
isn’t tenant-scoped), and the change-tracking that the dispatcher
provides. The boot lint rule rejects cross-feature imports for that
reason. The ctx.query / event / hook surface is the contract.
Common gotchas
Section titled “Common gotchas”r.requires("b")is mandatory when A hooks into B’s entity. Without it, the boot validator fails with a clear message — better than a silent runtime miss when B is removed from the feature list.- Idempotency on event handlers. Event-triggered jobs deliver at-least-once; the same event may fire twice on retries. Make the handler idempotent (use the entity ID as a deduplication key).
- Don’t emit events from
afterCommithooks. Usectx.emitfrom inside the original write or a domain event for that. An afterCommit hook that fires its own writes is bypassing the same atomicity guarantees you came here for.
Live example
Section titled “Live example”A feature emits order.placed from its write handler, and a
table-less multi-stream projection picks it up — runnable end-to-end:
export const pubsubOrderFeature = defineFeature("pubsubOrders", (r) => { r.entity("pubsub-order", orderEntity);
// Define the event shape once. The returned `.name` is the qualified // name ("pubsub-orders:event:order-placed") — pass it to ctx.appendEvent. const orderPlaced = r.defineEvent( "order-placed", z.object({ id: z.string(), customer: z.string(), product: z.string() }), );
const { executor: orderExecutor } = createEntityExecutor("pubsub-order", orderEntity);
r.writeHandler( "order:place", z.object({ customer: z.string().min(1), product: z.string().min(1) }), async (event, ctx) => { const result = await orderExecutor.create(event.payload, event.user, ctx.db); if (result.isSuccess) { // emitEvent runs inside the executor's TX and is typed against // the orderPlaced event-def — a wrong payload shape is a compile // error, not a runtime Zod reject. The event lands on the order's // OWN stream (aggregateType "pubsub-order"), with the correct // version bumped automatically. Schema validation still runs at // append-time; a mismatch rolls back the whole write. await emitEvent(ctx, orderPlaced, { aggregateId: String(result.data.id), aggregateType: "pubsub-order", payload: { id: String(result.data.id), customer: event.payload.customer, product: event.payload.product, }, }); } return result; }, { access: { roles: ["Admin", "Customer"] } }, );
// Async consumer — Marten-style. Fires after commit, via the event- // dispatcher. No `table` means this MSP is pure side-effect (no state // persisted). Delivery is at-least-once and ordered per consumer. r.multiStreamProjection({ name: "record-order-placed", apply: { [orderPlaced.name]: async (event) => { const payload = typedPayload(event, orderPlaced); capturedEvents.push({ type: event.type, payload, tenantId: event.tenantId, }); }, }, });Full source: samples/recipes/cross-feature-events.
See also
Section titled “See also”- Events and projections — what events look like and the two-phase hook contract.
- Features and composition — the six channels for cross-feature communication.
- Recipe: cross-feature-events — runnable end-to-end example.