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/clientWire 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.
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
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: