Skip to content

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/nats is 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

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

In 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

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

ts
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

MIT licensed.