TenancyJS
Core Concepts

Tenant context

How tenant identity flows through your app on AsyncLocalStorage - and why it fails closed.

Tenant context is the heart of TenancyJS. It answers one question at every layer of your app: which tenant is this code running for? - without you threading a tenantId through every function.

AsyncLocalStorage

The TenancyManager stores the active tenant in Node's AsyncLocalStorage. When you open a scope with runWithTenant, every async operation inside it - awaits, callbacks, ORM queries - sees the same tenant, with no manual plumbing.

await manager.runWithTenant({ id: "acme" }, async () => {
  manager.getTenant(); // { id: "acme" }
  await doWork(); // still "acme", however deep the call stack
});

You rarely call runWithTenant yourself in app code - your integration opens the scope per request. You reach for it directly in scripts, jobs, and tests.

Fail-closed

This is the core guarantee. A tenant-aware adapter used without a valid context does not return unscoped data - it throws.

// No active scope:
await db.order.findMany();
// ✗ TenantContextError - refuses to run unscoped

The safe default is "refuse," not "return everything." A missing context surfaces as a loud error at the boundary, not a silent leak three hops later.

Central context

Some work is legitimately cross-tenant - a superadmin dashboard, a billing rollup, a nightly job across all tenants. For that, open an explicit central scope. It's deliberate and visible in the code, never an accidental fallback.

await manager.runInCentralContext(async () => {
  // operates across tenants - by explicit choice
});

An unknown, suspended, or ambiguous tenant is never silently promoted to central context. If you want cross-tenant behaviour you have to ask for it by name.

Reading the current tenant

manager.getTenant(); // the tenant, or null in central scope
manager.getContext(); // { mode: "tenant", tenant } | { mode: "central" } | undefined

getContext() returns undefined when there's no active scope at all - useful for guarding code that may run both inside and outside a request.

Lifecycle & cleanup

Adapters run inside a transaction scoped to the tenant, and that scope is torn down on every path - success or error. There's no context left dangling between requests.

For setup and teardown around each tenant scope - priming a cache, opening a per-tenant resource - the manager supports bootstrappers and lifecycle listeners (tenancy.initializinginitializedendingended). A bootstrapper's revert always runs if its bootstrap ran, even when the scope fails, so you never leak a half-open resource.

Nesting a scope for a different tenant inside an active tenant scope is rejected - it's almost always a bug, and allowing it would be a subtle way to cross tenants. Open central scope explicitly if you need to step outside the current tenant.

On this page