Actions + events
When the handler's job is to mutate domain state, write it as an action that emits events instead of mutating the database directly.
- The action validates input and decides what facts happened.
- The events record those facts as past-tense values.
- The runtime persists the events, then notifies subscribers — projections, workflows, listeners in this app, and (for facts you publish as part of your contract) other apps too.
This is what lets one "place order" handler stay small while a dashboard, a confirmation email, and a warehouse webhook all react to the same fact — none of them wired into the handler. You get an audit trail and a live trace for free. The handler still looks like a normal async function; the framework does the fan-out.
A minimal example
// events.ts
import { defineEvent } from "@nwire/messages";
import { z } from "zod";
export const OrderWasPlaced = defineEvent({
name: "orders.order-was-placed",
schema: z.object({
orderId: z.string(),
customerId: z.string(),
total: z.number().positive(),
placedAt: z.string().datetime(),
}),
});// place-order.action.ts
import { randomUUID } from "node:crypto";
import { defineAction } from "@nwire/forge";
import { z } from "zod";
import { OrderWasPlaced } from "./events";
export const placeOrder = defineAction({
name: "orders.place-order",
input: z.object({
customerId: z.string(),
items: z.array(z.object({ sku: z.string(), qty: z.number().int().positive() })),
}),
emits: [OrderWasPlaced],
handler: async ({ input }) => {
const total = input.items.reduce((sum, i) => sum + i.qty * priceOf(i.sku), 0);
return OrderWasPlaced({
orderId: randomUUID(),
customerId: input.customerId,
total,
placedAt: new Date().toISOString(),
});
},
});The handler returns one or more event messages. Returning OrderWasPlaced({...}) records the event; the runtime persists it and notifies subscribers.
Wiring it into an HTTP route
// api.ts
import { post } from "@nwire/wires/http";
import { z } from "zod";
import { placeOrder } from "./place-order.action";
const placeOrderBinding = post("/orders", {
body: z.object({
customerId: z.string(),
items: z.array(z.object({ sku: z.string(), qty: z.number().int() })),
}),
});
export const wires = [
{ binding: placeOrderBinding, handler: placeOrder },
] as const;The action IS the handler — the wire references it directly, so the transport boundary hands the validated input straight to the domain. No dispatcher object to resolve.
If you need to run an action outside a wire (a script, a seed, a test), call it through the runtime:
import { placeOrder } from "./place-order.action";
await app.runtime.execute(placeOrder, { customerId: "c1", items: [] });The HTTP adapter detects forge handlers and routes them through runtime.execute, so action hooks (before/after, retry, telemetry) still fire.
Why bother (vs. a direct DB write)
Three things the events buy you that direct writes don't:
- Multiple consumers, one source of truth. A projection for the admin dashboard, a workflow that emails the customer, an outbound webhook to the warehouse — all subscribe to
OrderWasPlaced. None of them touches the order table; they fold the event stream. - A clean trace. Every dispatch produces telemetry: action started, event emitted, who reacted. Studio renders the chain. Replaying a bug becomes "find the event, fire it again."
- Wire-mode invariance. The same action runs on the HTTP route, from a queue worker, from the operator CLI. The handler body is identical; only the transport differs.
Multiple events from one action
emits accepts an array; the handler may return any subset:
emits: [OrderWasPlaced, InventoryWasReserved],
handler: async () => {
// Return multiple events as an array
return [
OrderWasPlaced({ /* ... */ }),
InventoryWasReserved({ /* ... */ }),
];
},Event naming conventions
- Past-tense PascalCase for the exported symbol:
OrderWasPlaced, notPlaceOrder(that reads like a command) and notOrderPlacedEvent(theEventsuffix is noise — the type tells you it's an event). - kebab-case past-tense for the
namefield, prefixed by the app or context:orders.order-was-placed. Studio and the manifest sort by prefix.
Local facts vs published facts
Everything so far stays inside one app. An unmarked event — returned or ctx.emitted — fans out to this app's subscribers (projections fold, workflows fire, listeners react) and never crosses the boundary. That's the default, and it's what keeps your internal events refactorable.
When a fact is part of your app's contract with other bounded contexts, mark the event .public() and announce it with ctx.publish:
import { defineAction } from "@nwire/forge";
import { defineEvent } from "@nwire/messages";
import { z } from "zod";
// The marked clone is the contract; export it, reference it everywhere.
export const OrderWasShipped = defineEvent({
name: "orders.order-was-shipped",
schema: z.object({ orderId: z.string(), shippedAt: z.string().datetime() }),
}).public();
export const shipOrder = defineAction({
name: "orders.ship-order",
input: z.object({ orderId: z.string() }),
emits: [OrderWasShipped],
handler: async ({ input, publish }) => {
const shippedAt = new Date().toISOString();
// Local subscribers still fire; the fact ALSO takes the outbound leg.
await publish(OrderWasShipped, { orderId: input.orderId, shippedAt });
return { shipped: true };
},
});publish runs the same local fold emit does, then hands the event to the outbound leg — the bus or outbound sink the deployment installed. Returning a .public() event declared in emits rides the same path — returned events go through the shared publish too. Two rules keep the boundary honest:
- Publishing an unmarked event throws.
publish: event 'X' is not .public() — emit it locally or mark it .public() to cross the boundary.An internal fact can't leak by accident. - Emitting a
.public()event is always fine. The marker widens what an event may do, never what it must —ctx.emitkeeps even a public event local.
Whether the fact actually leaves the process is deployment configuration, not handler code. A monolith with no bus folds it locally and drains to nothing; pass a bus to the forge plugin set (forgePlugins({ bus })) and the same handler publishes across services — see cross-service events.
Public events become the contract with other apps. Changing their shape is a breaking change — curate the .public() set deliberately; marking too much erodes the isolation the boundary buys you.
Where to next
- Actors — when the action needs to enforce invariants (only the order's owner can cancel it; can't ship a cancelled order).
- Projections + queries — fold the events into a read model that queries consume.
- Workflows — react to one event by dispatching the next action (auto-charge after order placed; email after charge).