Apps + composition
An app is your backend made runnable: the handlers, the plugins, and the container they share, built with createApp(...). It knows nothing about ports — an endpoint mounts it and serves it.
That's the whole primitive. When a system grows into several distinct areas — orders, payments, notifications — you build each as its own app and appCompose(...) them: one process in development, separate services in production, the same code either way. (If you think in domain terms, each app is a bounded context; you don't have to.)
A single-app shape
// orders/build.ts
import { createApp, defineRegistry } from "@nwire/app";
import { forgePlugins } from "@nwire/forge";
import { Order } from "./order.actor";
import { placeOrder, cancelOrder, shipOrder } from "./actions";
import { ordersDashboard, listOrders, orderTotals } from "./projections";
import { notifyOnOrderShipped } from "./workflows";
export function buildOrdersApp() {
return createApp({
appName: "orders",
registry: defineRegistry({
handlers: [placeOrder, cancelOrder, shipOrder, listOrders, orderTotals],
actors: [Order],
projections: [ordersDashboard],
workflows: [notifyOnOrderShipped],
}),
plugins: [...forgePlugins()],
});
}buildOrdersApp() is just a factory — it returns an unbooted App. app.start() boots plugins in order, then app.stop() reverses them.
Booting the app under an endpoint
import { endpoint } from "@nwire/endpoint";
import { httpKoa } from "@nwire/koa";
import { buildOrdersApp } from "./orders/build";
const app = buildOrdersApp();
// wire any HTTP routes (see guide/http-routes)
app.wire(/* ... */);
await app.start();
await endpoint("orders", { port: 3000 })
.use(httpKoa({ prefix: "/api/v1" }))
.mount(app)
.run();Multi-app composition (monolith)
When multiple bounded contexts deploy as one process, build each as its own App and compose:
import { appCompose } from "@nwire/app";
import { endpoint } from "@nwire/endpoint";
import { httpKoa } from "@nwire/koa";
import { buildOrdersApp } from "./orders/build";
import { buildPaymentsApp } from "./payments/build";
import { buildNotificationsApp } from "./notifications/build";
const orders = buildOrdersApp();
const payments = buildPaymentsApp();
const notifications = buildNotificationsApp();
const monolith = appCompose(orders, payments, notifications);
await orders.start();
await payments.start();
await notifications.start();
await endpoint("monolith", { port: 3000 })
.use(httpKoa({ prefix: "/api" }))
.mount(monolith)
.run();appCompose(...) merges wire collections; the Koa adapter dispatches each request through the source App's container per wire. No cross-context container leakage — each App keeps its own DI scope.
Talking across contexts — the public contract
Isolation cuts both ways: composed Apps can't resolve each other's bindings, so payments can't just reach into the orders store. What it can use is what orders marks .public() — the App's contract. Mark the queries and actions neighbours may use:
// orders/contract.ts
import { z } from "zod";
import { defineAction, defineQuery } from "@nwire/forge";
export const getOrder = defineQuery({
name: "orders.get-order",
input: z.object({ orderId: z.string() }),
handler: async ({ input, resolve }) => {
const store = resolve<Map<string, { total: number }>>("orders.store");
return store.get(input.orderId);
},
}).public();
export const refundOrder = defineAction({
name: "orders.refund-order",
input: z.object({ orderId: z.string() }),
handler: async ({ input }) => ({ refunded: input.orderId }),
}).public();The App instance exposes its .public() primitives as typed, destructurable properties (an internal primitive isn't there — the destructure won't type-check):
import { buildOrdersApp } from "./orders/build";
const orders = buildOrdersApp(); // registry includes the contract
const { getOrder, refundOrder } = orders; // the contract, typedA neighbour's handler uses the plain verbs — ctx.query to read, ctx.send to write. Composition routes the call to the owning App's runtime and container, threading the caller's envelope so one correlation chain covers the hop:
// payments/handle-chargeback.action.ts
import { defineAction } from "@nwire/forge";
import { z } from "zod";
import { getOrder, refundOrder } from "../orders/contract";
export const handleChargeback = defineAction({
name: "payments.handle-chargeback",
input: z.object({ orderId: z.string() }),
handler: async (ctx) => {
const order = await ctx.query(getOrder, { orderId: ctx.input.orderId }); // read
if (!order) return { ignored: true };
const ref = await ctx.send(refundOrder, { orderId: ctx.input.orderId }); // write
return { refund: ref };
},
});Reads are query (synchronous — awaited); writes are send (fire-and-forget, a MessageRef back). ctx.request — dispatch and await a result — never crosses the boundary, so splitting an App into its own process later doesn't change any call site. Dispatching a sibling's internal primitive throws not-found: a neighbour's internals are not reachable across the boundary.
See The bounded-context contract for the full model, including how routing and collision checks work.
Cross-app events
Inside one App, ctx.emit(Event, payload) is local fanout — its own when listeners fire, and the fact never leaves. To announce a fact to other Apps, mark the event .public() and use ctx.publish:
export const OrderWasRefunded = defineEvent({
name: "orders.order-was-refunded",
schema: z.object({ orderId: z.string() }),
}).public();
// inside the refund handler:
await ctx.publish(OrderWasRefunded, { orderId: input.orderId });publish runs the local fold, then drains the fact to whatever bus the deployment installed — an in-memory bus when the Apps share a process, NATS/AMQP when they don't. Publishing an event that isn't .public() throws, so internal facts can't leak. The consumer subscribes with the same when it uses at home and declares which foreign events it accepts (forgePlugins({ bus, externalEvents: [...] })). See Cross-service events for the bus setup.
Splitting a monolith into services
Because each App already keeps its own container + handlers, splitting is mostly a deployment change:
# Before — one process, three Apps composed
await endpoint("monolith", { port: 3000 })
.use(httpKoa())
.mount(appCompose(orders, payments, notifications))
.run();
# After — three processes, one App each
# (deployable A)
await endpoint("orders", { port: 3001 }).use(httpKoa()).mount(orders).run();
# (deployable B)
await endpoint("payments", { port: 3002 }).use(httpKoa()).mount(payments).run();
# (deployable C)
await endpoint("notifications", { port: 3003 }).use(httpKoa()).mount(notifications).run();Cross-service events that previously rode the in-process bus now go via NATS or AMQP. Domain code doesn't change — only the bus binding and boot wiring. One thing to budget for: a cross-context ctx.query that was an in-process call is now a network read. ctx.send and ctx.publish were fire-and-forget all along, so writes and events don't care.
Where to next
- The bounded-context contract —
.public(), the typed App surface, and how cross-context routing works. - Project structure — folder layouts for Apps and bounded contexts.
- Multi-transport — running the same App as HTTP + queue worker + MCP under one endpoint.
- Plugins — cross-cutting behaviour via
definePlugin. - Cross-service events — bus-based event flow between Apps in different processes.