TenancyJS
Set up your stack

NestJS + Prisma setup

Wire fail-closed multi-tenancy into a NestJS 11 + Prisma 7 app - a copy-pasteable setup-agent prompt, plus the manual walkthrough.

tenancy init doesn't scaffold NestJS yet, so this stack is a manual wire-up - a few files. This page gives you two ways to do it: hand the setup-agent prompt to your AI assistant, or follow the manual walkthrough. Either way, finish by running the two-tenant test - with fail-closed isolation you verify, you don't trust.

This is row-level on PostgreSQL. Prisma row-level is facade-enforced (the extension rewrites query arguments; there is no RLS backstop), so the one rule that matters most: only ever expose the extended client - never the base Prisma client.

Running MySQL? This page is PostgreSQL (row-level + forced RLS). MySQL has no schema-per-tenant and its row-level mode is experimental and facade-only - use database-per-tenant on MySQL instead.

Setup-agent prompt

Paste this into an assistant that can edit your repo and run commands (Claude Code, Cursor, …). It executes in order and verifies each step. It's pinned to the real API, so it won't invent factory names.

# TenancyJS Setup Agent - NestJS 11 + Prisma 7 (row-level, PostgreSQL)

You are a setup agent. Add fail-closed multi-tenancy to this NestJS + Prisma project with TenancyJS.
Execute every step in order. After each step, verify it applied before continuing. If a step does not
apply, say so and continue. Do NOT invent API names - if unsure, fetch the linked doc.

Source of truth (fetch if a detail is missing, do not guess):
- https://tenancyjs.pages.dev/docs/adapters/prisma
- https://tenancyjs.pages.dev/docs/integrations/nestjs
- https://tenancyjs.pages.dev/docs/concepts/limitations
- https://tenancyjs.pages.dev/docs/guides/testing-isolation

## Pre-flight
- Run `node -v`; abort if it is not >= 24.
- Confirm `@nestjs/core` (v11) and `@prisma/client` (v7) are in package.json. Abort if this is not a
  NestJS + Prisma project.
- Confirm the database is PostgreSQL.

## Step 1 - Install
Run:
```sh
npm install tenancyjs-core@beta tenancyjs-adapter-prisma@beta tenancyjs-integration-nest@beta tenancyjs-identifiers@beta @prisma/adapter-pg
```
Verify all five appear in package.json dependencies.

## Step 2 - Schema
In `prisma/schema.prisma`, ensure:
- A `Tenant` model with `id String @id` and `status String @default("active")`.
- At least one tenant-scoped model with a non-null `tenantId String` and an index on `tenantId`
  (e.g. `Post`). If the app already has domain models, add `tenantId` to each tenant-owned one.
Then run `npx prisma generate` (and `npx prisma migrate dev` if you own migrations).

## Step 3 - tenancy.ts (the single source of truth)
Create `src/tenancy.ts`. It must:
- Construct ONE `TenancyManager<Tenant>` and export it.
- Construct the BASE Prisma client with the Postgres driver adapter:
  `new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) })`.
- Create the EXTENDED client:
  `basePrisma.$extends(createPrismaTenancyExtension({ manager, tenantModels, centralModels }))`.
  Classify EVERY model: each tenant model as `{ tenantField: "tenantId", relationFields: [...] }`,
  `Tenant` under `centralModels`. Unknown models are rejected at runtime.
- Build a `TenantResolutionChain` with `new HeaderTenantResolver()` and a store whose `find(identifier)`
  looks the tenant up via the BASE client and returns `[{ tenant: { id }, status }]` (or `[]`).
- Export `manager`, `resolver`, and the EXTENDED client as `prisma`. Do NOT export the base client for
  app use.

## Step 4 - PrismaService
Create `src/prisma.service.ts`: an `@Injectable()` provider whose value IS the extended `prisma` client
(re-export it via a provider, or `useValue: prisma`). Controllers inject THIS, never the base client.

## Step 5 - Register TenancyModule
In `app.module.ts`, add `TenancyModule.forRoot({ manager, resolver })` to imports (import both from
`./tenancy`). Do not register a second guard/interceptor.

## Step 6 - Scope the routes
For every controller handler that touches tenant data:
- Add `@TenantRoute()` (from `tenancyjs-integration-nest`).
- Query through the injected extended `prisma` client. REMOVE any manual `where: { tenantId }` filters -
  the extension injects the predicate. Leave non-tenant/public routes unmarked.

## Step 7 - Central (cross-tenant) work
For admin/cross-tenant handlers, do NOT leave them unscoped. Wrap the work in
`manager.runInCentralContext(() => ...)` explicitly. Unscoped tenant access must throw.

## Step 8 - Lock the boundary
- Ensure the base Prisma client is never provided to controllers/services - only the extended client.
- Do not use raw queries or nested relation writes on the extended client; they are rejected by the
  facade. If you need them, that's a database-per-tenant concern (see the adapter doc), not row-level.

## Step 9 - Prove it
Create a two-tenant test (Vitest or Jest): create tenant A and tenant B, write a row under EACH with the
SAME primary key, assert querying as A never returns B's row, and assert that a tenant query with no
active scope THROWS. Follow https://tenancyjs.pages.dev/docs/guides/testing-isolation.

## Step 10 - Verify
- `npx nest build` (or `npm run build`) - report errors.
- Boot the app (`npm run start`) and confirm it serves.
- Run the two-tenant test and report the result.

Report a summary of every file created/changed and any issue found.

AI wiring is a starting point, not proof. The only thing that demonstrates isolation is the two-tenant colliding-id test in Step 9 - run it before you trust the result.

Manual walkthrough

Prefer to wire it yourself? The same steps, with the code.

Install

npm install tenancyjs-core@beta tenancyjs-adapter-prisma@beta tenancyjs-integration-nest@beta tenancyjs-identifiers@beta @prisma/adapter-pg

One tenancy.ts

The manager, the extended Prisma client, and the resolver all live here - one source of truth.

src/tenancy.ts
import { TenancyManager, type TenantRecord } from "tenancyjs-core";
import { createPrismaTenancyExtension } from "tenancyjs-adapter-prisma";
import { HeaderTenantResolver, TenantResolutionChain } from "tenancyjs-identifiers";
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";

interface Tenant extends TenantRecord {
  readonly id: string;
}

export const manager = new TenancyManager<Tenant>();

// Prisma 7 needs a driver adapter; keep the BASE client private to this module.
const basePrisma = new PrismaClient({
  adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});

// The ONLY client the app may use. Classify every model.
export const prisma = basePrisma.$extends(
  createPrismaTenancyExtension({
    manager,
    tenantModels: { Post: { tenantField: "tenantId", relationFields: [] } },
    centralModels: { Tenant: { relationFields: [] } },
  }),
);

export const resolver = new TenantResolutionChain({
  resolvers: [new HeaderTenantResolver()], // X-Tenant-Id
  store: {
    async find(identifier) {
      const tenant = await basePrisma.tenant.findUnique({
        where: { id: identifier.value },
      });
      return tenant ? [{ tenant: { id: tenant.id }, status: tenant.status }] : [];
    },
  },
});

Expose the extended client as a provider

src/prisma.service.ts
import { Global, Module } from "@nestjs/common";
import { prisma } from "./tenancy";

export const PRISMA = Symbol("PRISMA");

@Global()
@Module({
  providers: [{ provide: PRISMA, useValue: prisma }],
  exports: [PRISMA],
})
export class PrismaModule {}

Inject @Inject(PRISMA) wherever you query. The base client is never provided.

Register the module

src/app.module.ts
import { Module } from "@nestjs/common";
import { TenancyModule } from "tenancyjs-integration-nest";
import { manager, resolver } from "./tenancy";
import { PrismaModule } from "./prisma.service";

@Module({
  imports: [TenancyModule.forRoot({ manager, resolver }), PrismaModule],
})
export class AppModule {}

Scope your routes

Mark tenant routes; the interceptor opens the scope and the extended client reads it - no manual tenantId filter.

src/posts.controller.ts
import { Controller, Get, Inject } from "@nestjs/common";
import { TenantRoute } from "tenancyjs-integration-nest";
import { PRISMA } from "./prisma.service";
import type { prisma as Prisma } from "./tenancy";

@Controller("posts")
export class PostsController {
  constructor(@Inject(PRISMA) private readonly prisma: typeof Prisma) {}

  @Get()
  @TenantRoute()
  list() {
    return this.prisma.post.findMany(); // scoped to the request's tenant
  }
}

Prove isolation

Write the two-tenant colliding-id test: same primary key under A and B, assert A never reads B's row, and assert an unscoped tenant query throws.

Cross-tenant admin work runs in an explicit central scope - manager.runInCentralContext(() => …). There is no request-controlled central mode; a resolution failure fails closed, never central.

Full lifecycle and the security boundary: NestJS integration · Prisma adapter · Limitations.

On this page