Cross-service bus + integration events
@nwire/bus is the contract for cross-process event delivery. @nwire/bus-nats is the NATS adapter. Both follow the same adapter pattern: contract package ships an in-memory default, adapter packages plug in opt-in.
When you need it
In a monolith, modules talk via local events — runtime.publish(event) fans out to every in-process subscriber synchronously. No bus needed.
The moment you split modules into separate services, the same event has to cross a network. The bus is what carries it.
The promise: module code doesn't change. Topology decides whether the event goes local or external.
Declaring an external need
// inside the lessons module
defineModule("lessons", {
actions: [advanceLesson],
actors: [Lesson],
needs: {
externalEvents: [
{ event: SubmissionWasGraded, from: "submissions" },
],
},
})At boot, createApp checks:
- If the
submissionsmodule is loaded in this process → wire the in-process subscription. - If
submissionsis in a different service → wire a bus subscription on the topicsubmissions.submission-was-graded.
Topology
The deployment shape is data:
// apps/topologies/split.topology.ts
export const splitTopology = {
services: {
"submissions-svc": { apps: ["submissions-app"], bus: { backend: "nats", url: process.env.NATS_URL } },
"lessons-svc": { apps: ["lessons-app"], bus: { backend: "nats", url: process.env.NATS_URL } },
},
}apps/run.ts reads the topology, boots one service per declared shape, and wires the bus. Same modules; monolith if you point one service at all apps, split if you give each app its own service.
Integration event vs internal event
| Internal event | Integration event |
|---|---|
defineEvent({ name: "submissions.answer-was-submitted" }) | Same definition |
audience: ["product"] | audience: ["product", "lessons"] (declares cross-BC use) |
| Local subscribers only | Local + bus subscribers |
The event definition is identical; the cross-service contract emerges from the needs.externalEvents declarations of consuming modules.
Reliability
- Events flow through an outbox (
defineOutbox) before the bus, so a failed publish doesn't lose the fact. - Consumers use an inbox (
defineInbox) for idempotency — replaying the same event is a no-op. - The bus adapter is responsible for at-least-once delivery; the inbox makes the consumer at-most-once-effectively.
See also
- Telemetry pipeline — every bus publish/receive is a telemetry record
- Multi-transport recipe — running the same module behind HTTP + queue + bus