The bounded-context contract
When a system grows into several apps composed with appCompose, each app keeps its own container and runtime — billing cannot reach into the courses app's stores, and that isolation is the point. But neighbours still need each other: billing reads the course catalog before invoicing, courses hands the actual sign-up back to enrollments.
.public() is how an app names the handful of primitives its neighbours may use. A query, an action, or an event marked .public() is part of the app's contract; everything unmarked is internal and stays unreachable from the outside — enforced at the type level and again at runtime.
Mark the contract
.public() works on queries, actions, and events. It returns a marked clone, so the definition file stays visibility-agnostic — you can mark at the export, or later in the registry:
import { z } from "zod";
import { defineAction, defineQuery } from "@nwire/forge";
import { defineEvent } from "@nwire/messages";
// The contract — what neighbours may use.
export const listCourses = defineQuery({
name: "courses.list-courses",
input: z.object({}),
handler: async () => [{ id: "hebrew-letters", title: "Hebrew Letters" }],
}).public();
export const enrollStudent = defineAction({
name: "courses.enroll-student",
input: z.object({ studentId: z.string() }),
handler: async ({ input }) => ({ enrolled: input.studentId }),
}).public();
export const StudentWasEnrolled = defineEvent({
name: "courses.student-was-enrolled",
schema: z.object({ studentId: z.string(), courseId: z.string() }),
}).public();
// Internal — Dina's catalog cleanup. No marker, no exposure.
export const normalizeCatalog = defineAction({
name: "courses.normalize-catalog",
input: z.object({}),
handler: async () => ({ normalized: true }),
});The typed surface
The app instance exposes its .public() primitives as destructurable properties, keyed by the camelCased last segment of the name (courses.list-courses → listCourses; events get the PascalCase event casing — courses.student-was-enrolled → StudentWasEnrolled). The property is the definition itself — the reference you hand to ctx.query / ctx.send / when — never a bound callable:
import { z } from "zod";
import { createApp, defineRegistry } from "@nwire/app";
import { defineAction, defineQuery, forgePlugins } from "@nwire/forge";
const listCourses = defineQuery({
name: "courses.list-courses",
input: z.object({}),
handler: async () => [{ id: "hebrew-letters", title: "Hebrew Letters" }],
}).public();
const normalizeCatalog = defineAction({
name: "courses.normalize-catalog",
input: z.object({}),
handler: async () => ({ normalized: true }),
});
const coursesApp = createApp({
appName: "courses",
registry: defineRegistry({ handlers: [listCourses, normalizeCatalog] }),
plugins: [...forgePlugins()],
});
// A neighbour destructures the contract — and only the contract.
const { listCourses: catalog } = coursesApp;
// @ts-expect-error — internal primitives never appear on the surface type
const { normalizeCatalog: leaked } = coursesApp;The external surface type contains only .public() primitives, so destructuring an internal one fails to type-check — and the property is absent at runtime too. Two public primitives that would collide on one surface key fail loudly at createApp: a surface a neighbour can't destructure unambiguously is not a contract.
Crossing the boundary
There are no special cross-app verbs. A neighbour uses the verb that matches its intent, and the same call works whether the target app lives in the same process or not:
| Intent | Verb | What you get |
|---|---|---|
| read | ctx.query(listCourses, input) | the result, awaited |
| write | ctx.send(enrollStudent, input) | a MessageRef — fire-and-forget |
| subscribe | when(StudentWasEnrolled, handler) | the fact, as it happens |
| announce | ctx.publish(StudentWasEnrolled, payload) | local fold + the outbound leg |
ctx.request — ask-and-wait command dispatch — stays inside an app. A write across the boundary is always send: it returns a reference to the dispatched message, not a result, so splitting the target app into its own process later doesn't change the call site.
The whole crossing, in one file:
import { z } from "zod";
import { appCompose, createApp, defineRegistry } from "@nwire/app";
import { defineAction, defineQuery, forgePlugins } from "@nwire/forge";
// ── courses — owns the catalog ──────────────────────────────────────
const listCourses = defineQuery({
name: "courses.list-courses",
input: z.object({}),
handler: async () => [{ id: "hebrew-letters", title: "Hebrew Letters" }],
}).public();
const enrollStudent = defineAction({
name: "courses.enroll-student",
input: z.object({ studentId: z.string() }),
handler: async ({ input }) => ({ enrolled: input.studentId }),
}).public();
const courses = createApp({
appName: "courses",
registry: defineRegistry({ handlers: [listCourses, enrollStudent] }),
plugins: [...forgePlugins()],
});
// ── billing — reads the catalog, hands the sign-up back ─────────────
const chargeEnrollment = defineAction({
name: "billing.charge-enrollment",
input: z.object({ studentId: z.string() }),
handler: async (ctx) => {
const catalog = await ctx.query(listCourses, {}); // read
const ref = await ctx.send(enrollStudent, { // write
studentId: ctx.input.studentId,
});
return { invoicedAgainst: catalog, enrollment: ref };
},
});
const billing = createApp({
appName: "billing",
registry: defineRegistry({ handlers: [chargeEnrollment] }),
plugins: [...forgePlugins()],
});
const monolith = appCompose(courses, billing);
await monolith.start();The ctx.query runs on the courses runtime and container — billing never sees a courses store, only the query's result. The ctx.send dispatches through the courses pipeline (retry, hooks, telemetry) and returns a MessageRef immediately. Both thread the caller's envelope as parent, so one correlation chain stitches the trace across the boundary.
How routing works
Composing seals the contract. appCompose collects every leaf app's .public() primitives into one lookup — name → owning runtime — and installs it on each leaf. When a handler dispatches a definition:
- Local first. An app always resolves its own primitives before consulting the lookup — a local name shadows a sibling's public one, and a standalone (uncomposed) app never routes anywhere else.
- Public siblings route to their owner. The call runs on the owning app's runtime and container, with the caller's envelope threaded as parent.
- Everything else throws. A sibling's internal primitive is not found — the error says so: "…is not
.public()— a composed sibling's internals are not reachable across the boundary." An app that was never composed in stays unreachable too.
Two apps exposing the same public name is an ambiguous contract and throws at compose (or at start, when the duplicate arrives through discovery) — the same policy as the wire-collision guard.
Events: emit stays home, publish crosses
Inside an app, ctx.emit(Event, payload) is local fanout — when listeners in the same app fire, and the fact never leaves. ctx.publish(Event, payload) is the cross-boundary verb: it runs the local fold, then the outbound sink/bus leg. Only .public() events may cross — publishing an unmarked event throws.
A neighbour subscribes with the same when it uses at home; the fact arrives over the bus the deployment installed (an in-memory bus for one process, NATS or AMQP for split services). The subscribing app declares which foreign events it accepts:
// billing/app.ts — accept the courses fact over the bus
import { StudentWasEnrolled } from "../courses/contract";
const billing = createApp({
appName: "billing",
registry,
plugins: [
...forgePlugins({ bus, externalEvents: [StudentWasEnrolled.name] }),
],
});
billing.when(StudentWasEnrolled, async (fact, ctx) => {
await ctx.send(openInvoice, { studentId: fact.studentId });
});Which transport carries the fact is the deployment's decision, never the handler's — the publish call and the when block don't change when the apps split. See Cross-service events for the bus setup.
The honest tradeoffs
- A cross-boundary
queryis synchronous. Fine for a read — there's no write coupling — but if you later split the owning app into its own process, that read becomes a network hop. Budget for it, or subscribe to the owner's events and keep a local read model instead. - Isolation is contract-level, not process-level. Composed apps share a process; the framework stops your code from reaching a sibling's internals, not your database credentials. The boundary is as honest as your infrastructure bindings.
- The contract is only as good as its curation. Every
.public()is a promise a neighbour may build on. Mark the three things your neighbours actually need, not everything that might be handy — over-publishing turns the contract back into a shared internals folder.
See also
- Apps + composition — building and composing the apps this contract seals.
- Events and listeners —
emitandwheninside one app. - Cross-service events — the bus that carries
.public()events between processes. - Queries — what a
.public()query is under the hood.