App
A bounded context made runnable: a container + plugins + wires that adopters mount.
The App is the foundation primitive. One repo can declare multiple apps that compose into a monolith via appCompose(...), or deploy separately as their own services — same domain code either way.
There is no separate Module primitive. An App is the bounded context. Multi-BC systems compose multiple apps; they don't nest modules.
Shape
An App is structure: a name, its registry, and its plugins. It reads no environment and binds no port — those are deployment concerns supplied at boot.
import { createApp } from "@nwire/app";
import { forgePlugins } from "@nwire/forge";
import { registry } from "virtual:nwire-registry";
export const submissionsApp = createApp({
appName: "submissions",
registry, // every define* under the app, registered
plugins: [...forgePlugins()], // the forge battery — reads the runtime by kind
});One registration channel
You write your primitives — submitAnswer, gradeAnswer, autoGrade, submissionsByStudent — as define* exports under the app's source tree, and they are registered for you through the registry. The @nwire/scan unplugin generates virtual:nwire-registry from that source; nothing is hand-listed, and no primitive is ever passed to a plugin. A battery like forge then scans the runtime by kind for the actors, projections, and workflows it drives.
Where there's no bundler — a test, an edge target — build the registry by hand with the same shape:
import { createApp, defineRegistry } from "@nwire/app";
import { forgePlugins } from "@nwire/forge";
export const submissionsApp = createApp({
appName: "submissions",
registry: defineRegistry({
handlers: [submitAnswer, gradeAnswer, listSubmissions],
actors: [Submission],
projections: [submissionsByStudent],
workflows: [autoGrade],
}),
plugins: [...forgePlugins()],
});The App is an unbooted value. The entry binds a port and supplies config at boot — config is the deployment's values, kept apart from the app's structure:
import { config } from "virtual:nwire-config";
await endpoint("submissions", config.app.endpoint)
.use(httpKoa(config.http))
.mount(submissionsApp, config) // binds config; handlers read ctx.config
.run();Multi-app composition
Each bounded context is its own App with its own registry — under a multi-app layout, each reads its scoped module (virtual:nwire-registry/submissions). One entry composes them onto a single endpoint:
import { appCompose } from "@nwire/app";
import { config } from "virtual:nwire-config";
import { submissionsApp } from "./apps/submissions/app";
import { enrollmentsApp } from "./apps/enrollments/app";
const monolith = appCompose(submissionsApp, enrollmentsApp);
await endpoint("monolith", config.app.endpoint)
.use(httpKoa(config.http))
.mount(monolith, config)
.run();appCompose merges wire collections; the adopter dispatches each request through the source App's container per wire. Each App keeps its own DI scope. Split a context out to its own process later and only this composition line changes.
What an App owns
- Registry — every primitive, registered by kind (handlers dispatch; actors/projections/workflows/commands/external-calls land in
byKindfor plugins to read) - Container —
app.container(with per-request scoping viacontainer.createScope()), where the resolvedconfigis bound at boot - Wire collection —
app.interface.wires(read by adopters at boot) - Plugins — boot/dispose ordering, framework hooks
- Runtime — handlers, dispatch chain, telemetry stream
- Framework events bus — typed lifecycle events (
AppBooting,AppBooted,PluginBooting, etc.)
Multi-tenancy
Tenant scoping lives on the envelope (envelope.tenant) which threads through every dispatch. HTTP adopters read x-tenant-id (or x-tenant) by default; queue adopters carry it on every job; bus adopters preserve it across services.
Apps don't declare a tenant model — handlers + projections key on envelope.tenant directly. See Multi-tenancy for the patterns.
See also
createApp+ composition- Runtime
- Topology — how apps actually run under adopters