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.

Feature B write ctx.write("incidents:resolve") Write transaction handler body + projections Hook: inTransaction DB writes that must roll back with it Commit Hook: afterCommit path 2 — external side effects Triggered job path 1 — at-least-once, runs in worker Feature A side effect mail, webhook, downstream write
  • 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() }),
{ piiFields: "none" },
);
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.