Skip to content

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:

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

ts
// 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()
bash
pnpm exec vite-node apps/main/__wires__/dev-all.ts
# → all 4 apps on :3000, in-memory bus

This is the dev default. Cross-app reactions fire in-process; observability sees one process, one timeline.

Split — one wire per app

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

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

BusWhenAdapter
In-memoryDev, tests, monolithsbuilt-in (@nwire/bus)
NATSProduction split, simple ops@nwire/bus-nats
KafkaHigh-throughput, replaybring your own (the pattern is the same)
Redis StreamsCheap, "good enough"bring your own

Adapter contract is a 3-method interface: publish(event), subscribe(name, handler), close(). Implement once; same modules work across.

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

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

ts
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

MIT licensed.