TenancyJS
Adapters

Prisma

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

tenancyjs-adapter-prisma scopes Prisma queries to the active tenant via a Prisma Client extension. It supports row-level (PostgreSQL + MySQL 🧪 experimental), 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: (req) => ({ id: req.subdomains.at(-1) ?? "" }),
  }),
);

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. Row-level Prisma isolation is adapter-enforced query rewriting on both databases; using an unextended client bypasses it.

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