Multi-tenant SaaS architecture on NestJS + Prisma
Every SaaS becomes multi-tenant the day customer #2 signs up. The isolation decision you make that week follows you for years, so here's how we actually decide — with the trade-offs we've hit running NestJS + Prisma + Postgres systems in production.
The two real options
Row-level isolation: one schema, a tenantId on everything
Every tenant-owned table carries a tenantId column; every query filters on it. One database, one migration, one connection pool.
Wins: operationally boring. Migrations run once. Cross-tenant analytics are a query, not an ETL job. Connection pooling just works — this matters more than people think, because Postgres connections are a hard resource and per-tenant anything multiplies them.
Costs: isolation is enforced by your code being correct everywhere, forever. One forgotten where: { tenantId } and tenant A sees tenant B's invoices. That's not a bug, that's a churn event and possibly a legal one.
Schema-per-tenant: one Postgres schema per customer
Wins: a missing filter fails loudly instead of leaking data. "Your data is physically separated" is a sentence enterprise buyers like. Per-tenant backup/restore and even per-tenant migration timing become possible.
Costs: migrations become a fleet operation — at 200 tenants, a deploy runs 200 migrations, and you need tooling for the ones that fail halfway. Prisma is built around one schema per client instance, so you're managing a client (and its connection overhead) per active tenant. At a few hundred tenants this is real engineering; at a few thousand it's your main job.
How we decide
Our default: row-level isolation, with the tenant filter enforced by machinery instead of memory. Schema-per-tenant only when there's a hard external driver — usually compliance ("our data must be separable"), a handful of large tenants rather than thousands of small ones, or a credible on-prem/single-tenant tier on the roadmap.
The mistake we see most is choosing schema-per-tenant for a 50-tenant product because it "feels safer," then drowning in migration tooling that a two-person team has no business maintaining.
The Prisma patterns that hold up
Make the tenant filter impossible to forget
The core trick is a request-scoped Prisma client extension that injects the filter:
// prisma.service.ts — tenant-scoped client via $extends
forTenant(tenantId: string) {
return this.$extends({
query: {
$allModels: {
async $allOperations({ args, query, operation }) {
if (TENANT_SCOPED_OPS.has(operation)) {
args.where = { ...args.where, tenantId };
}
return query(args);
},
},
},
});
}
In NestJS, resolve tenantId in a guard (from the JWT or subdomain — never from the request body), stash it in AsyncLocalStorage or a request-scoped provider, and have services receive the scoped client. The unscoped client lives in one module with a scary name, used only by admin and billing jobs. Code review rule: any import of the raw client outside that module is an automatic rejection.
Belt and suspenders: Postgres RLS
For anything handling money or health data we add Postgres row-level security under the application filter: CREATE POLICY tenant_isolation ... USING (tenant_id = current_setting('app.tenant_id')::uuid), with the setting applied per transaction. Two independent mechanisms both have to fail before data leaks. The measured overhead in our workloads: low single-digit percent.
Lessons from production
- Composite indexes must lead with
tenantId.(tenantId, createdAt),(tenantId, status). We've watched a dashboard go from 40ms to 4s at tenant #80 because an index didn't include the tenant column and Postgres chose a full scan. - Uniqueness is per-tenant.
@@unique([tenantId, email]), not@uniqueon email. You will hit this the first time two customers both employ someone named info@. - Test cross-tenant denial explicitly. Our integration suites seed two tenants and assert that every list endpoint returns zero rows from the other one. These tests have caught real leaks twice — both times in hand-written raw SQL that bypassed the extension.
$queryRawis where isolation goes to die; audit every instance. - One noisy tenant will find you. Per-tenant rate limiting and query timeouts from day one; a
tenantIdlabel on your metrics so you can see who is slow.
Multi-tenancy isn't a library you install. It's a set of invariants you enforce everywhere, and the architecture question is really: enforced by what — memory, machinery, or the database? Pick at least machinery.
If you're scoping something like this, book a 30-minute call — bring your schema and we'll give you a straight answer on which model fits.