Skip to content

Multi-transport + multi-service

The same wires run on HTTP, on a queue worker, on an MCP tool, or split across services — no code change. Each shape is just a different entry script that picks adapters.

This guide covers the two common shapes: one process serving many apps (monolith), and one entry per service (split deployment).

The setup

Three bounded contexts in one repo, each its own App:

ts
// apps/index.ts
import { buildSubmissionsApp } from "../packages/submissions"
import { buildEnrollmentsApp } from "../packages/enrollments"
import { buildMasteryApp } from "../packages/mastery"

export { buildSubmissionsApp, buildEnrollmentsApp, buildMasteryApp }

Monolith — one process, every app composed

ts
// apps/main/main.ts
import { appCompose } from "@nwire/app"
import { endpoint } from "@nwire/endpoint"
import { httpKoa } from "@nwire/koa"
import { buildSubmissionsApp, buildEnrollmentsApp, buildMasteryApp } from "../index.js"

const submissions = buildSubmissionsApp()
const enrollments = buildEnrollmentsApp()
const mastery = buildMasteryApp()

const monolith = appCompose(submissions, enrollments, mastery)

await submissions.start()
await enrollments.start()
await mastery.start()

await endpoint("monolith", { port: 3000 })
  .use(httpKoa({ prefix: "/api", inspect: true }))
  .mount(monolith)
  .run()

This is the dev default. Cross-app reactions fire in-process; one process, one timeline, one set of probes.

Multi-adapter — same app, many transports

Stack adapters to serve the same wires under HTTP + queue + MCP:

ts
import { endpoint } from "@nwire/endpoint"
import { httpKoa } from "@nwire/koa"
import { queueInMemory } from "@nwire/queue"
import { mcpAdapter } from "@nwire/mcp"

await endpoint("svc", { port: 3000 })
  .use(httpKoa({ prefix: "/api" }))
  .use(queueInMemory())
  .use(mcpAdapter())
  .mount(app)
  .run()

Wires whose binding is http(...) serve HTTP; wires with queue(...) process queue jobs; wires with tool(...) expose as MCP tools. One handler set, three surfaces.

Split — one entry per app

ts
// apps/lms/main.ts
import { endpoint } from "@nwire/endpoint"
import { httpKoa } from "@nwire/koa"
import { natsBus } from "@nwire/nats"
import { mongoActorStore, mongoProjectionStore } from "@nwire/mongo"
import { pinoLogger } from "@nwire/logger-pino"
import { buildEnrollmentsApp } from "../packages/enrollments"

const app = buildEnrollmentsApp({
  logger: pinoLogger({ level: "info" }),
  bus: natsBus({ servers: process.env.NATS_URL! }),
  actorStore: mongoActorStore({ uri: process.env.MONGO_URL! }),
  projectionStore: mongoProjectionStore({ uri: process.env.MONGO_URL! }),
})

await app.start()

await endpoint("enrollments", { port: Number(process.env.PORT ?? 3001) })
  .use(httpKoa())
  .mount(app)
  .run()

One file per service: apps/submissions/main.ts, apps/mastery/main.ts. Each runs on its own port, with its own store partition, its own appName tag on telemetry. Cross-app event flow goes through NATS.

In production each app deploys as a separate container running its own entry — no manifest, no central composer.

Cross-service event flow

When enrollments publishes enrollments.student-enrolled:

enrollments process                          mastery process
─────────────────────────────────────────    ────────────────────────
runtime.emit(StudentEnrolled)
  → in-process workflows/projections fire
  → bus.publish (when bus is attached
    and the event is published-to-bus)
                                             bus.subscribe receives
                                               → runtime.apply event
                                               → in-process workflows/
                                                 projections fire

Same event name, same payload, same envelope (correlationId preserved across the bus). Workflows in mastery don't know whether the event came from enrollments or from a local publish — that's the framework's job.

Picking a bus

BusWhenAdapter
In-memoryDev, tests, monolithsbuilt-in (@nwire/bus)
NATSProduction split, simple ops@nwire/nats
AMQPExisting RabbitMQ infra@nwire/amqp
Kafka / Redis StreamsHigh-throughput, replaybring your own

The bus adapter contract is a three-method interface: publish(event), subscribe(name, handler), close(). Implement once; same domain code works across.

Trace context across services

envelope.correlationId travels via bus events automatically — Nwire threads it through. OpenTelemetry's traceparent is a separate header the bus adapter forwards when configured; see @nwire/telemetry-otel for the end-to-end setup.

When to split, when to keep monolith

Stay monolith

  • Single team owns the whole domain
  • Single deploy unit is simpler ops
  • Cross-context flows fire synchronously in-process (lower latency)
  • One database is enough (or you partition by tenant inside one)

Split into services

  • Different teams own different BCs and need independent release cadence
  • One BC has wildly different scaling needs (compute-heavy / IO-heavy)
  • Compliance requires data separation (PII service vs analytics)
  • Independent failure domains matter (auth must stay up if billing is down)

Don't split because microservices are fashionable

Splitting adds: bus latency, eventual consistency, retry/dedup complexity, trace propagation work, deploy coordination, schema-evolution discipline. Stay monolith until you have a clear reason.

The right pattern with Nwire: start monolith. Apps are the bounded- context boundary. When a real reason to split arrives, the entry-file swap is mechanical — same App, different endpoint(...).use(...).mount() configuration.

See also

MIT licensed.