Multi-service split
The same modules can run as a single monolith or as N separate services. The difference is one string in the topology manifest. No code change, same observability.
The setup
Three modules in one Nwire repo, three apps that compose them differently:
// apps/index.ts
import { submissionsModule, enrollmentsModule, masteryModule, lessonsModule } from "../modules"
export const learnflowApp = defineApp("learnflow", {
modules: [submissionsModule, enrollmentsModule, masteryModule, lessonsModule],
tenantModel: "per-org",
tenantKey: "schoolId",
})
export const lmsApp = defineApp("lms", { modules: [enrollmentsModule, rosterModule] })
export const lxApp = defineApp("lx", { modules: [submissionsModule, lessonsModule] })
export const competencyApp = defineApp("competency", { modules: [masteryModule] })Monolith — one process, every app
// apps/main/__wires__/dev-all.ts
import { httpInterface } from "@nwire/http"
import { lmsApp, lxApp, competencyApp, learnflowApp } from "../index.js"
import { config } from "../../../config"
await httpInterface({ port: config.http.port, inspect: true, publishToBus: true })
.wire(learnflowApp)
.wire(lmsApp)
.wire(lxApp)
.wire(competencyApp)
.run()pnpm exec vite-node apps/main/__wires__/dev-all.ts
# → all 4 apps on :3000, in-memory busThis is the dev default. Cross-app reactions fire in-process; observability sees one process, one timeline.
Split — one wire per app
// apps/lms/__wires__/main.ts
import { httpInterface } from "@nwire/http"
import { natsBus } from "@nwire/bus-nats"
import { mongoActorStore, mongoProjectionStore } from "@nwire/store-mongo"
import { pinoLogger } from "@nwire/logger-pino"
import { lmsApp } from "../index.js"
await httpInterface({
port: Number(process.env.PORT ?? 3001),
inspect: false,
publishToBus: true,
bus: natsBus({ servers: process.env.NATS_URL! }),
actorStore: mongoActorStore({ uri: process.env.MONGO_URL! }),
projectionStore: mongoProjectionStore({ uri: process.env.MONGO_URL! }),
logger: pinoLogger({ level: "info" }),
})
.wire(lmsApp)
.run()One file per service: apps/lx/__wires__/main.ts, apps/competency/__wires__/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 wire script — no manifest, no central composer.
Cross-service event flow
When LX publishes submissions.auto-graded:
LX process Competency process
───────────────────────────────────────── ─────────────────────────
runtime.publish(SubmissionAutoGraded)
→ in-process actors/projections/reactions fire
→ telemetry: event.published (source=in-process)
→ bus.publish (because publishToBus=true,
visibility=public)
bus.subscribe receives
→ runtime.applyExternalEvent(...)
→ in-process actors/projections/
reactions fire
→ telemetry: event.published
(source=external)Same event name, same payload, same envelope (correlationId preserved across the bus). Reactions in competency don't know whether the event came from LX or from a local publish — that's the framework's job.
Declaring cross-service needs
Each module declares which events it consumes from elsewhere:
// modules/mastery/mastery.module.ts
export const masteryModule = defineModule("mastery", {
needs: {
events: [
// declared as "I subscribe to these events from any provider, local or remote"
SubmissionAutoGradedEvent,
SubmissionManuallyGradedEvent,
],
},
// ...
})createApp validates at boot:
- If the event is provided by another module in this app's manifest → in-process wiring (monolith mode)
- If not → require a bus, auto-subscribe over the bus (split mode)
- If neither → boot fails with a helpful error
The same needs.events declaration works for both topologies. The framework's wiring picks the right path.
Picking a bus
| Bus | When | Adapter |
|---|---|---|
| In-memory | Dev, tests, monoliths | built-in (@nwire/bus) |
| NATS | Production split, simple ops | @nwire/bus-nats |
| Kafka | High-throughput, replay | bring your own (the pattern is the same) |
| Redis Streams | Cheap, "good enough" | bring your own |
Adapter contract is a 3-method interface: publish(event), subscribe(name, handler), close(). Implement once; same modules work across.
// In your wire entry:
import { httpInterface } from "@nwire/http"
import { natsBus } from "@nwire/bus-nats"
import { mongoActorStore } from "@nwire/store-mongo"
await httpInterface({
bus: natsBus({ servers: config.bus.url, prefix: config.bus.prefix }),
actorStore: mongoActorStore({ uri: config.mongo.url }),
})
.wire(app)
.run()Trace context across services
Studio's correlation chain travels via envelope.correlationId — Nwire threads it through the bus automatically. But OpenTelemetry's trace context is a SEPARATE thing (traceparent header / W3C TraceContext).
To propagate OTel trace context across NATS hops:
// In the publisher (LX):
import { context, propagation } from "@opentelemetry/api"
bus.publish({
...event,
// Standard W3C TraceContext header carried as bus metadata
traceParent: propagation.serializeTraceParent(context.active()),
})
// In the subscriber (Competency):
bus.subscribe(eventName, (msg, meta) => {
const ctx = propagation.deserializeTraceParent(meta.traceParent)
context.with(ctx, () => runtime.applyExternalEvent(eventName, msg.payload, msg.envelope))
})This is bus-adapter-specific. @nwire/bus-nats will ship this in v0.2; today you wire it manually. The correlationId works either way for Studio + Greptime queries.
Testing the split
import { harness } from "@nwire/test-kit"
import { InMemoryEventBus } from "@nwire/bus"
it("LX → competency: auto-graded triggers mastery contribution", async () => {
// Shared bus simulates the cross-service link in-process
const bus = new InMemoryEventBus()
const lx = await harness({ app: lxApp, providers: { bus, publishToBus: true } })
const competency = await harness({ app: competencyApp, providers: { bus } })
await lx.dispatch(submitAnswer, { studentId: "avi", answer: "alef" })
await lx.idle()
// bus delivers the event into competency synchronously (in-memory)
await competency.idle()
expect(
competency.telemetry.count("event.published",
e => e.event.eventName === "mastery.contribution-recorded"),
).toBe(1)
await lx.stop()
await competency.stop()
})When to split, when to keep monolith
Stay monolith
- Single team owns the whole domain
- Single deploy unit is simpler ops
- Cross-bounded-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. Modules are the bounded-context boundary. When a real reason to split arrives, the wire- file swap is mechanical — same modules, different wires.
See also
- Concepts → Topology — wire-file deployment shapes
- @nwire/bus — bus contract
- Studio guide → Run page — supervise split topologies