Skip to content

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

ts
// 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(),
  }),
});
ts
// 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

ts
// 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:

ts
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:

  1. 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.
  2. 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."
  3. 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:

ts
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, not PlaceOrder (that reads like a command) and not OrderPlacedEvent (the Event suffix is noise — the type tells you it's an event).
  • kebab-case past-tense for the name field, 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:

ts
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.emit keeps 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).

MIT licensed.