Skip to content

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.

  • You’ve read Events and projections so the default <entity>.created event is familiar.
  • A is a feature in your codebase; B is either yours or a bundled feature.

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.

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.

  • 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 afterCommit hooks. Use ctx.emit from 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.

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.