Any framework
TenancyJS core is framework-agnostic. Wire the two-step integration - resolve the tenant, then run the request in its scope - into Fastify, Koa, Hono, tRPC, or plain Node yourself.
TenancyJS's core is framework-agnostic. The tenancyjs-integration-* packages (Express, Next.js,
NestJS, AdonisJS) are ~30-line conveniences — there's nothing magic in them. If you're on Fastify, Koa,
Hono, tRPC, or plain Node, wire the same two steps yourself.
npx tenancyjs-cli init only scaffolds Express, Next.js, and AdonisJS. On any other framework it exits
and asks you to pick a supported one — you don't need it; wire the two steps below by hand.
The two steps
Every integration does exactly this, per request:
- Resolve the tenant from the request with a
TenantResolutionChain, and fail closed if it can't (describeTenantResolutionFailuremaps the failure to 400 / 404 / 500). - Scope the rest of the request by running it inside
manager.runWithTenant(tenant, () => …). Any tenant-aware data access outside a scope throws — that's the guarantee.
The manager, resolver, and adapter are identical to every other stack — only the request plumbing differs.
import { TenancyManager } from "tenancyjs-core";
import { HeaderTenantResolver, TenantResolutionChain } from "tenancyjs-identifiers";
export interface Tenant {
readonly id: string;
}
export const manager = new TenancyManager<Tenant>();
export const resolver = new TenantResolutionChain<Tenant>({
resolvers: [new HeaderTenantResolver({ headerName: "x-tenant-id" })],
store: {
async find(identifier) {
const tenant = await lookupTenant(identifier.value);
return tenant ? [{ tenant, status: "active" }] : [];
},
},
// Required: does THIS authenticated principal belong to the resolved tenant?
authorize: ({ tenant, principal }) => isMember(principal, tenant),
});Frameworks with a next() you await (Koa, Hono, tRPC, Connect)
If your framework's middleware hands you a next() that you call and await, wrap it — next() runs the
rest of the request inside the scope, exactly like the Express integration does.
import { describeTenantResolutionFailure } from "tenancyjs-identifiers";
import { manager, resolver } from "./tenancy";
app.use(async (ctx, next) => {
const outcome = await resolver.resolve(
{ host: ctx.host, headers: ctx.headers },
{ principal: ctx.state.user }, // run this AFTER your auth middleware
);
if (outcome.status !== "resolved") {
const { status, message } = describeTenantResolutionFailure(outcome.status);
ctx.status = status;
ctx.body = { message };
return;
}
// Everything downstream runs in the tenant scope.
await manager.runWithTenant(outcome.tenant, () => next());
});Hono is the same shape — (c, next) => manager.runWithTenant(tenant, () => next()) — and so is a tRPC
middleware: ({ next }) => manager.runWithTenant(tenant, () => next()).
Fastify (hooks don't wrap the handler)
Fastify's hooks run before the handler and can't wrap it, so runWithTenant in a hook wouldn't
cover the route. Resolve in an onRequest hook, then run each handler inside the scope with a small
wrapper:
import { describeTenantResolutionFailure } from "tenancyjs-identifiers";
import { manager, resolver } from "./tenancy";
app.addHook("onRequest", async (req, reply) => {
const outcome = await resolver.resolve(
{ host: req.hostname, headers: req.headers },
{ principal: req.user }, // after your auth
);
if (outcome.status !== "resolved") {
const { status, message } = describeTenantResolutionFailure(outcome.status);
return reply.code(status).send({ message });
}
req.tenant = outcome.tenant; // stash for the wrapper (augment FastifyRequest to type it)
});
// Run a route handler inside its request's tenant scope.
const scoped =
(handler) =>
(req, reply) =>
manager.runWithTenant(req.tenant, () => handler(req, reply));
app.get("/orders", scoped(async () => db.order.findMany())); // scoped to req.tenantPlain Node http
Wrap the request handler — everything it awaits stays in the scope:
import { createServer } from "node:http";
import { describeTenantResolutionFailure } from "tenancyjs-identifiers";
import { manager, resolver } from "./tenancy";
createServer(async (req, res) => {
const outcome = await resolver.resolve({ host: req.headers.host, headers: req.headers });
if (outcome.status !== "resolved") {
const { status } = describeTenantResolutionFailure(outcome.status);
res.writeHead(status).end();
return;
}
await manager.runWithTenant(outcome.tenant, () => handle(req, res));
});The rules (the same everywhere)
- Resolve after authentication. The
authorizehook needs the real principal — resolving a tenant is not authorizing the user. See Resolving tenants. - Never leave cross-tenant work unscoped. For admin/aggregate paths, run inside
manager.runInCentralContext(() => …)explicitly. Unscoped tenant access throws by design. - Fail closed on every failure status.
describeTenantResolutionFailurereturns 400 for a missing/invalid identifier, 404 for not-found/suspended/forbidden (deliberately indistinguishable), and 500 for ambiguous.
Your adapter (Prisma, Drizzle, TypeORM, …) doesn't change at all — it scopes to whatever tenant the manager has active, no matter which framework put it there.