TenancyJS
Adapters

Prisma

Row, PostgreSQL schema, and database-per-tenant routing for Prisma 7.

tenancyjs-adapter-prisma scopes Prisma queries to the active tenant. Row-level works two ways: a Prisma Client extension that rewrites queries (facade-only, PostgreSQL + MySQL 🧪 experimental), or an RLS-backed path on PostgreSQL where forced RLS is the enforcement and you get full query freedom (see below). It also supports PostgreSQL schema-per-tenant and database-per-tenant on PostgreSQL and MySQL.

This page shows it wired end to end with Express; for another framework, swap only the integration (see below).

Install

npm install tenancyjs-core tenancyjs-adapter-prisma tenancyjs-integration-express @prisma/client

Wire it into your app (row-level)

Create the manager + extended client

Register your tenant-scoped models, then apply the adapter's extension to your Prisma client. The extended client is what you query with.

tenancy.ts
import { TenancyManager } from "tenancyjs-core";
import { createPrismaAdapter } from "tenancyjs-adapter-prisma";
import { PrismaClient } from "@prisma/client";

export interface Tenant {
  readonly id: string;
}

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

const adapter = createPrismaAdapter({
  manager,
  tenantModels: { Order: {}, Post: {} },
});

// Apply the tenancy extension - `db` is your tenant-scoped Prisma client.
export const db = new PrismaClient().$extends(adapter.extension);

Bind it to requests

server.ts
import express from "express";
import { createExpressTenancyMiddleware } from "tenancyjs-integration-express";
import { manager, db } from "./tenancy";

const app = express();

app.use(
  createExpressTenancyMiddleware({
    manager,
    resolver, // a TenantResolutionChain (tenancyjs-identifiers) - see the Express guide
  }),
);

app.get("/orders", async (_req, res) => {
  res.json(await db.order.findMany()); // only the current tenant's orders
});

Inside a tenant scope the extension injects the tenant filter on reads and the tenant field on writes; outside a scope it fails closed. This is the facade-only path: isolation is adapter-enforced query rewriting with no database backstop, so an unextended client - or a query the rewriter doesn't cover (a raw $queryRaw, some nested writes) - bypasses it. Use it when you can't run a Prisma driver adapter. For a database backstop on PostgreSQL, use the RLS-backed path below.

RLS-backed row-level (PostgreSQL)

On PostgreSQL you can back row-level isolation with forced RLS instead of trusting the facade. createPrismaRowLevelTenancy returns { validate, run }. run opens a Prisma interactive transaction, SET LOCALs the tenant GUC the RLS policy reads, and hands your callback a tenant-scoped tx (a full PrismaClient). Model queries, nested relations, and raw SQL all run under the policy - so a query that slips a rewriter still cannot cross tenants, and a raw cross-tenant write is rejected by the policy's WITH CHECK. It needs a driver adapter (@prisma/adapter-pg) and forced RLS under a non-BYPASSRLS role (generate the DDL with tenancy policy).

tenancy.ts
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { createPrismaRowLevelTenancy } from "tenancyjs-adapter-prisma";
import { manager } from "./manager";

// A driver adapter is required so the transaction can SET LOCAL the tenant GUC.
const client = new PrismaClient({
  adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL! }),
});

export const tenancy = createPrismaRowLevelTenancy({
  manager,
  client,
  // Each entry maps a Prisma model to its table + tenant column:
  // { model, table, tenantColumn, tenantField?, schema?, policyName? }.
  tables: [{ model: "post", table: "posts", tenantColumn: "tenant_id" }],
});

await tenancy.validate(); // checks forced RLS + the policy contract are in place
// inside a request, already resolved to `tenant`
const posts = await manager.runWithTenant(tenant, () =>
  tenancy.run(async (tx) => tx.post.findMany()), // full query freedom, RLS-scoped
);

Here RLS is the enforcement, not the facade: forced RLS + a non-BYPASSRLS role + the tenancy policy DDL. Inside run you have full Prisma freedom - model queries, nested relations, and raw SQL - and the tenant is auto-injected on writes. A raw cross-tenant write is rejected by the database, not by a rewriter. The extension path above stays available for setups without a driver adapter.

Database per tenant

Route a dedicated Prisma client per tenant with createPrismaDatabaseTenancy. You supply how to create and dispose a client for a given tenant; clients are pooled in a bounded cache and reused.

import { createPrismaDatabaseTenancy } from "tenancyjs-adapter-prisma";

const tenancy = createPrismaDatabaseTenancy({
  manager,
  connection: (tenant) => ({
    key: tenant.databaseKey, // opaque: never put a URL or credential here
    create: () => createPrismaClient(tenant.databaseSecretRef),
  }),
  disconnect: (client) => client.$disconnect(),
  maxConnections: 25,
});

The client is valid only inside the run callback; do not store or return it after the lease ends.

Full query freedom is inherent here - no unrestricted() needed. The database-per-tenant router hands your callback the raw leased PrismaClient directly; there's no restricted facade to escape, so raw queries, relations, and $queryRaw all work. The client you get is the tenant's own database, so any query is isolated by construction, and it still fails closed in central mode (ADR-0033).

PostgreSQL schema per tenant

Prisma model queries do not follow a runtime search_path. Prisma 7 instead exposes an explicit schema option on its PostgreSQL driver adapter:

import { PrismaPg } from "@prisma/adapter-pg";
import { createPrismaSchemaTenancy } from "tenancyjs-adapter-prisma";

const tenancy = createPrismaSchemaTenancy({
  manager,
  schema: (tenant) => ({
    key: tenant.schemaKey,
    create: () =>
      new PrismaClient({
        adapter: new PrismaPg(
          { connectionString: process.env.DATABASE_URL! },
          { schema: tenant.schemaName },
        ),
      }),
  }),
  disconnect: (client) => client.$disconnect(),
});

The router proves one tenant maps to one cached client placement. A shared database role may still have permission on sibling schemas; use schema-restricted roles for database-side denial.

Use a different framework

Keep the adapter half; only swap the integration import:

On this page