Topology
Deployment shape is what your entry file says. Nwire doesn't ship a "topology manifest" primitive. Each deployable is a small TypeScript file under apps/<name>/main.ts that builds an App (or composes several via appCompose), boots an endpoint(...), and chooses adopters via .use(...). Monolith vs split is a question of how many entry files you write — same domain code either way.
Monolith — one entry, all apps composed
// apps/main/main.ts
import { appCompose } from "@nwire/app"
import { endpoint } from "@nwire/endpoint"
import { httpKoa } from "@nwire/koa"
import { buildLmsApp } from "@amit/lms"
import { buildLxApp } from "@amit/lx"
import { buildCompetencyApp } from "@amit/competency"
const lms = buildLmsApp()
const lx = buildLxApp()
const competency = buildCompetencyApp()
const monolith = appCompose(lms, lx, competency)
await Promise.all([lms.start(), lx.start(), competency.start()])
await endpoint("monolith", { port: Number(process.env.PORT ?? 3000) })
.use(httpKoa({
inspect: true,
openapi: { auto: true, title: "AMIT", version: "1.0.0" },
docs: true,
}))
.mount(monolith)
.run()All three apps share one HTTP server and one in-process event bus. The default for nwire dev.
Split — one entry per service
// apps/lms/main.ts
import { endpoint } from "@nwire/endpoint"
import { httpKoa } from "@nwire/koa"
import { natsBus } from "@nwire/nats"
import { buildLmsApp } from "@amit/lms"
const app = buildLmsApp({
bus: natsBus({ servers: process.env.NATS_URL! }),
})
await app.start()
await endpoint("lms", { port: Number(process.env.PORT ?? 3001) })
.use(httpKoa())
.mount(app)
.run()// apps/lx/main.ts — same shape, different port + app
import { endpoint } from "@nwire/endpoint"
import { httpKoa } from "@nwire/koa"
import { natsBus } from "@nwire/nats"
import { buildLxApp } from "@amit/lx"
const app = buildLxApp({
bus: natsBus({ servers: process.env.NATS_URL! }),
})
await app.start()
await endpoint("lx", { port: Number(process.env.PORT ?? 3002) })
.use(httpKoa())
.mount(app)
.run()Cross-app events go through NATS. Same domain code. Deploy each entry as its own container.
Adding a new deployment
Add one file under apps/<name>/main.ts. Wire it in package.json scripts. No central manifest to update.
{
"scripts": {
"dev:all": "vite-node apps/main/main.ts",
"dev:lms": "PORT=3001 vite-node apps/lms/main.ts",
"dev:lx": "PORT=3002 vite-node apps/lx/main.ts"
}
}Provider swapping
The bus, actor store, projection store, logger — everything that ships an adapter — is swapped at the App's plugin layer (or passed to the build factory):
import { MongoClient } from "mongodb"
import { createApp, definePlugin } from "@nwire/app"
import { createMongoActorStore, MongoProjectionStore } from "@nwire/mongo"
import { createPinoLogger } from "@nwire/logger-pino"
const db = (await MongoClient.connect(process.env.MONGO_URL!)).db("orders")
const app = createApp({
appName: "orders",
plugins: [
definePlugin("stores", ({ bind }) => {
bind("actor.store", createMongoActorStore(db))
bind("projection.store", new MongoProjectionStore(db.collection("projections")))
}),
definePlugin("logger", ({ bind }) => {
bind("logger", createPinoLogger({ level: "info" }))
}),
// ... forgePlugins, etc.
],
})In dev, omit the persistent stores — the InMemory defaults that ship with @nwire/forge take over automatically.
Multi-adopter on one app
Stack adopters on one endpoint to serve the same wires under more transports:
await endpoint("svc", { port: 3000 })
.use(httpKoa({ prefix: "/api" }))
.use(queueInMemory())
.use(mcpAdapter())
.mount(app)
.run()