Skip to content
Tezbyte
← All insights

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 @unique on 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. $queryRaw is 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 tenantId label 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.