Events and listeners
An event is your app announcing that something happened — UserRegistered, OrderPaid. A listener is code that reacts to it, living in its own file. The part that emits the event doesn't know or care who's listening.
This is how you keep a "create user" handler from slowly absorbing email, analytics, and billing code. It's the everyday decoupling tool — reach for it the way you would events and listeners in Laravel, typed end to end. You don't need anything heavier to use it.
Announce: emit an event
An event is a past-tense fact. Define it with a schema, then emit it from a handler through ctx.emit:
import { defineEvent } from "@nwire/messages";
import { z } from "zod";
export const UserRegistered = defineEvent({
name: "users.user-registered",
schema: z.object({ userId: z.string(), email: z.string() }),
});// register-user.handler.ts
const registerUser = async (input: { email: string }, ctx) => {
const userId = crypto.randomUUID();
// … persist the user …
await ctx.emit(UserRegistered, { userId, email: input.email });
return { userId };
};The handler's job is done. It doesn't send the welcome email, doesn't touch analytics, doesn't know anyone is listening.
React: a listener file
A listener is its own file — a group of whens, never mixed into a request handler. when(Event, handler) is the one verb for reacting to an event; the handler receives the payload and a context (send, emit, resolve, logger, envelope):
// listeners/welcome.ts
import { subscribe } from "@nwire/app";
import { UserRegistered } from "../users.events";
import { sendWelcomeEmail } from "../notifications/send-welcome-email.handler";
export default subscribe((when) => {
when(UserRegistered, async (user, ctx) => {
await ctx.send(sendWelcomeEmail, { email: user.email });
});
});ctx.send dispatches a follow-up with the original event's correlation preserved, so a trace shows the whole chain stitched together: registerUser → UserRegistered → welcome listener → sendWelcomeEmail.
Register the listener on the app:
import welcomeListeners from "./listeners/welcome";
app.subscribe(welcomeListeners);That's the whole pattern. Add a second when to the same file for another reaction; add another file for an unrelated concern. Nothing in registerUser changes.
The rule: no when inside a handler
Reactions live in listener files, not in request handlers. A handler does the one thing it was called to do and emits a fact; listeners decide what that fact triggers. Keeping them apart is what stops a "create user" endpoint from slowly accumulating email, analytics, and billing code.
emit stays local, publish crosses
ctx.emit is local delivery: listeners in this app fire, and the event never leaves the boundary. That's the right default — most facts are implementation detail, and nobody outside your app should couple to them.
When a fact is part of your app's contract with the outside — another bounded context, another service — two things change. The event carries the .public() marker, and the handler announces it with ctx.publish instead:
import { defineEvent } from "@nwire/messages";
import { z } from "zod";
// `.public()` marks the event as part of this app's contract.
// The original definition stays unmarked; export the marked clone.
export const OrderWasPlaced = defineEvent({
name: "orders.order-was-placed",
schema: z.object({ orderId: z.string(), total: z.number() }),
}).public();// Inside a handler:
await ctx.emit(DraftWasSaved, { orderId }); // local — this app only
await ctx.publish(OrderWasPlaced, { orderId, total }); // local + crossespublish does everything emit does — local listeners still fire — and then hands the event to the outbound leg (the bus or sink the deployment installed). The guardrail is hard: in an action handler, publishing an event that isn't .public() throws, so an internal fact can never leak across the boundary by accident. The reverse is always fine — emitting a .public() event keeps it local; the marker widens what an event may do, never what it must.
Whether a published fact actually leaves the process is the wire's decision, not the handler's. A monolith with no bus installed folds it locally and drains to nothing; a split deployment installs a bus or an outbound sink and the same ctx.publish call crosses the process. Same domain code, any deployment shape.
In an action handler publish is already on the ctx. A plain app (no forge) installs it as a capability — same emit-then-drain composition, except an unmarked event simply stays local instead of throwing:
import { createApp, publishPlugin } from "@nwire/app";
const app = createApp({ appName: "orders" }).with(publishPlugin());When it grows up
A listener is stateless: it reacts to one event and acts. When a reaction needs to remember something across events, correlate by a key, or wait on a timer, it becomes a workflow — the samewhen(Event, (payload, ctx) => …) block, with state and timers added. You graduate by adding what you need, not by relearning the shape.