Multi-tenancy
When one process needs to serve many tenants — schools, organisations, workspaces — Nwire partitions every actor, every projection, and every store by tenant automatically. Every dispatch carries the tenant through the envelope; the runtime keys storage per-tenant. No cross-tenant bleed, no per-tenant fork.
The model
There's no tenantModel setting — the surface is simpler: the envelope carries the tenant. Handlers, projections, and queries that need it read envelope.tenant (or take it from ctx when a scoped capability is installed).
import { createApp, defineRegistry } from "@nwire/app";
import { forgePlugins } from "@nwire/forge";
const app = createApp({
appName: "orders",
registry: defineRegistry({
handlers: [placeOrder],
actors: [Order],
projections: [ordersDashboard],
}),
plugins: [...forgePlugins()],
});How requests get tagged
In development, the HTTP transport reads the x-tenant-id header (the shorter x-tenant also works) and stamps it onto the message envelope:
curl -X POST http://localhost:3000/api/orders \
-H 'x-tenant-id: acme' \
-H 'content-type: application/json' \
-d '{"customerId":"alice","items":[{"sku":"abc","qty":1}]}'Inside the handler, ctx.envelope.tenant === "acme". Every actor loaded via ctx.actor(Actor, id), every projection folded by the runtime, and every store op happens against the acme partition.
In production the raw header is refused — a spoofable client header must never decide tenancy. Provide a tenantResolver that derives the tenant from a verified source (a JWT claim, a session, the host):
httpKoa({
tenantResolver: (kctx) => (kctx.state.user as { tenant?: string })?.tenant,
});Without a resolver, envelope.tenant is left undefined outside development, so nothing routes to the wrong partition by accident.
Bypassing tenancy
Some routes run before a tenant exists — sign-in, signup, public health checks. Those handlers just don't read envelope.tenant, and the wire that calls them doesn't require the header.
If a downstream component (a projection store, an actor) needs the tenant and the envelope doesn't carry one, the runtime treats it as the empty string "" — the "no tenant" partition. Use that for unauthenticated paths; gate write paths behind auth so anonymous requests can't pollute it.
Cross-process flow
The envelope is serialized when a dispatch goes onto a queue or across the bus, and deserialized on the other side. Workers and downstream services see the same envelope.tenant — no manual passing. Outbound sinks preserve it; inbound adapters parse it back out.
When to split databases
In-process partitioning is the default and the cheapest path. Two cases where you'd consider separate databases per tenant:
- Compliance. Tenant data must be physically isolated (HIPAA, some EU regulators).
- Skewed load. One tenant is 100× larger than the others; sharing a database means their query plans dominate everyone else's.
For both, swap the relevant store via a sub-plugin override. For example, replace the in-memory projection store with a Postgres one that opens per-tenant connections:
projectionsPlugin({
projectionStore: tenantAwareProjectionStore(connectionFor),
});See forge sub-plugins for the swap pattern.
See also
- HTTP routes — where the tenant header lands in the envelope.
- Cross-service events — envelope propagation across services.
- Sub-plugins — replace the store backing actors, projections, idempotency, or DLQ per tenant.