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:
// 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
// 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:
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
// 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 fireSame 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
| Bus | When | Adapter |
|---|---|---|
| In-memory | Dev, tests, monoliths | built-in (@nwire/bus) |
| NATS | Production split, simple ops | @nwire/nats |
| AMQP | Existing RabbitMQ infra | @nwire/amqp |
| Kafka / Redis Streams | High-throughput, replay | bring 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
- Cross-service events — what flows over the bus between split services.
- Apps + composition — how the apps you wire here are built.
- Project structure — folder layout for monorepos with multiple deployables.