Skip to content

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).

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

bash
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):

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

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

MIT licensed.