Skip to content

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:

ts
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-courseslistCourses; events get the PascalCase event casing — courses.student-was-enrolledStudentWasEnrolled). The property is the definition itself — the reference you hand to ctx.query / ctx.send / when — never a bound callable:

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

IntentVerbWhat you get
readctx.query(listCourses, input)the result, awaited
writectx.send(enrollStudent, input)a MessageRef — fire-and-forget
subscribewhen(StudentWasEnrolled, handler)the fact, as it happens
announcectx.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:

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

  1. 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.
  2. 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.
  3. 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:

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

MIT licensed.