Cross-service events (NATS)
When two services need to react to each other's events without a direct HTTP call, ship them over a bus. Nwire's @nwire/bus ships a contract
- in-memory default;
@nwire/natsis the production adapter.
Concept
┌──────────────┐ ┌──────────────┐
│ orders svc │── NATS bus ──▶│ inventory svc│
│ │ │ │
│ publishes │ │ subscribes │
│ OrderWasPlaced│ │ OrderWasPlaced│
└──────────────┘ └──────────────┘The producer publishes a .public() event. The consumer declares the subscription via the externalEvents option on forgePlugins. The runtime wires the NATS client at boot.
Producer
// orders/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(), items: z.array(z.string()) }),
}).public(); // required for cross-service — the contract markerIn the orders service, the handler announces the fact with ctx.publish(OrderWasPlaced, …) (or returns it from an action that declares it in emits). Only .public() events reach the bus — the runtime folds everything locally first, then drains the marked events to the wire (NATS, AMQP, etc.). ctx.emit stays local and never touches the bus, and an action's ctx.publish throws for an unmarked event, so internal facts can't leak into the integration contract. See actions + events.
Consumer
// inventory/workflows/reserve-stock-on-order.ts
import { defineWorkflow } from "@nwire/forge";
import { OrderWasPlaced } from "@my-app/orders"; // shared contract
export const reserveStockOnOrder = defineWorkflow(
"reserve-stock-on-order",
({ when, send }) => {
when(OrderWasPlaced, async (event) => {
await send(reserveStock, { orderId: event.orderId, items: event.items });
});
},
);The consumer App registers the workflow in its registry; forge folds it. When a bus adapter is passed to forgePlugins({ bus, externalEvents }), every declared external event is subscribed at boot and delivered to local subscribers identically to in-process events (the app's own published echoes are skipped by origin).
The workflow body doesn't know whether the event came from the local process or NATS. That's the framework's job.
Configure the bus
import { createApp, defineRegistry } from "@nwire/app";
import { forgePlugins } from "@nwire/forge";
import { natsBus } from "@nwire/nats";
import { OrderWasPlaced } from "@my-app/orders";
const app = createApp({
appName: "inventory",
registry: defineRegistry({ workflows: [reserveStockOnOrder] }),
plugins: [
...forgePlugins({
bus: natsBus({
servers: process.env.NATS_URL!.split(","),
prefix: "myco", // optional subject prefix
}),
externalEvents: [OrderWasPlaced.name], // facts accepted from the bus
}),
],
});The bus passed to forgePlugins wires NATS as the runtime's inbound subscription transport; externalEvents names the foreign facts this App accepts — each is subscribed at boot, deduped by message id, and folded through the same delivery path a local event rides. On the producing side, wiring a bus into the forge plugins is the opt-in — published .public() events drain to it. With no bus, cross-service subscribers simply never receive — boot still succeeds.
Trace context
envelope.correlationId flows over the bus automatically. OTel traceparent propagation isn't automatic yet — wire it explicitly in the bus adapter callback if needed.
Where to next
- The bounded-context contract —
.public()as the whole contract: events, queries, and actions. - Multi-transport — running the same modules as monolith and split across services.
- Workflows — what reacts to cross-service events.
defineEventreference — the full event API, including the.public()contract marker.