Multi-tenancy in NestJS without losing your mind
Retrofitting multi-tenancy onto an API that was never designed for it is one of those tasks that looks like a two-day job and turns into a two-month one. Here is what I learned doing exactly that.
Three ways to isolate tenant data
There is no universally correct answer — only trade-offs you can live with.
- Database per tenant — the strongest isolation, the worst migration story. Every schema change multiplies by your customer count.
- Schema per tenant — a decent middle ground on PostgreSQL, but connection pooling gets awkward fast.
- Shared schema with a
tenant_idcolumn — the simplest to operate, and the easiest to get catastrophically wrong.
We went with the third. It is the right default for most products, provided you never rely on developers remembering to filter.
The mistake: passing tenantId by hand
The first version threaded the tenant through every service call:
async findInvoices(tenantId: string, filters: InvoiceFilters) {
return this.prisma.invoice.findMany({
where: { tenantId, ...filters },
});
}
It works, right up until someone adds a query and forgets the where clause. That is not
a bug you find in code review — it is a bug you find in a support ticket from the wrong
customer.
If data isolation depends on discipline, it is not isolation. It is a convention with good intentions.
What actually worked
Resolve the tenant once, at the edge, and make it impossible to query without it. A
request-scoped provider reads the tenant from the authenticated token, and a Prisma
extension applies the filter to every model that has a tenantId:
@Injectable({ scope: Scope.REQUEST })
export class TenantContext {
constructor(@Inject(REQUEST) private readonly request: AuthedRequest) {}
get id(): string {
const tenantId = this.request.user?.tenantId;
if (!tenantId) throw new UnauthorizedException('No tenant in context');
return tenantId;
}
}
Two things made this stick:
- Fail closed. A missing tenant throws. It never falls back to "all rows".
- Test the negative case. Every resource has a test that asserts tenant A cannot read tenant B's data. Those tests have caught more regressions than any other suite we have.
What I would do differently
Enable row-level security in PostgreSQL from day one. Application-level filtering is a good second layer, but the database is the only place the guarantee actually lives.