DEV Community

Nathan Cook
Nathan Cook

Posted on • Updated on

NestJS + Prisma + PostgreSQL ~ RLS multi-tenancy using nestjs-prisma, nestjs-cls and Prisma Client Extensions

(Github repository for this article)

Here's a simple way to do multi-tenancy in NestJS with Prisma using Client Extensions and AsyncLocalStorage (via nestjs-cls).

This post describes the basics of a generic NestJS implementation. Look at this repo for a complete and functional example app. Checkout branch async-hooks to see the AsyncLocalStorage-based implementation (which relates to this post). The repo also demonstrates how to use request-scoped providers (main branch) and durable request-scoped providers (durable branch).

Step #1: Setup AsyncLocalStorage using nestjs-cls in your bootstrap function:

// main.ts
import {ClsMiddleware} from 'nestjs-cls';

async function bootstrap() {
  // init app...

  app.use(
    new ClsMiddleware({
      async setup(cls, req) {
        cls.set('TENANT_ID', req.params('tenant_id'));
      },
    }).use
  );

  // etc
}
Enter fullscreen mode Exit fullscreen mode

Step #2: Add ClsModule.forRoot({ global:true }) to your App Module (app.module.ts) imports.

Step #3: Create a file that exports a custom Factory Provider that returns an extended Prisma client.

// prisma-tenancy.provider.ts
import { PrismaModule, PrismaService } from 'nestjs-prisma';
import { ClsService } from 'nestjs-cls';

const useFactory = (prisma: PrismaService, store: ClsService) => {
    return prisma.$extends({
        query: {
            $allModels: {
                async $allOperations({ args, query }) {
                    const tenantId = store.get('TENANT_ID');


                    const [, result] = await prisma.$transaction([
                        prisma.$executeRaw`SELECT set_config('tenancy.tenant_id', ${`${tenantId || 0}`}, TRUE)`,
                        query(args),
                    ]);

                    return result;
                },
            },
        },
    });
};

export type ExtendedTenantClient = ReturnType<typeof useFactory>;

export const TENANCY_CLIENT_TOKEN = Symbol('TENANCY_CLIENT_TOKEN');

export const PrismaTenancyClientProvider = {
    provide: TENANCY_CLIENT_TOKEN,
    imports: [PrismaModule],
    inject: [PrismaService, ClsService],
    useFactory
};
Enter fullscreen mode Exit fullscreen mode

Step #4: Create a module that exports the above Factory Provider

// prisma-tenancy.module.ts
import { Module, Global } from '@nestjs/common';
import { PrismaTenancyClientProvider, TENANCY_CLIENT_TOKEN } from './prisma-tenancy.provider';
import { PrismaModule } from 'nestjs-prisma';

@Global()
@Module({
    imports: [PrismaModule],
    providers: [PrismaTenancyClientProvider],
    exports: [TENANCY_CLIENT_TOKEN]
})
export class PrismaTenancyModule { }
Enter fullscreen mode Exit fullscreen mode

Step #5: Add the module above to your root app.module.ts imports.

Now, you can inject the extended client using the token for your custom provider (TENANCY_CLIENT_TOKEN, in this case).

import {Injectable, Inject} from "@nestjs/common";
import {
  TENANCY_CLIENT_TOKEN,
  ExtendedTenantClient,
} from "./prisma-tenancy.provider";

@Injectable()
export class SomeService {
  constructor(
    @Inject(TENANCY_CLIENT_TOKEN) private readonly prisma: ExtendedTenantClient
  ) {}

  // etc
}
Enter fullscreen mode Exit fullscreen mode

Useful Links

Top comments (5)

Collapse
 
nhattien015 profile image
nhattien015 • Edited

Recently, I published a NestJS, GraphQL, TypeORM, PosgresQL example in real project. Switch to Prisma easily if you want to. Here is my source code: github.com/hnhattien/nest-graphql-... I hope you can feel good

Collapse
 
mahee profile image
Mahee Gamage • Edited

Great article, I have been looking for this. Can you explain why we need to use useFactory? Are we using multiple prismaClients per each tenant?

Collapse
 
moofoo profile image
Nathan Cook • Edited

useFactory is employed because we want to inject the extended prisma client returned by calling prisma.$extend(...), and you need useFactory for the sort of dynamic provider that's necessary to do this.

More importantly, creating the extended prisma client with a Custom Factory Provider means the NestJS Dependency Injection system can be utilized when creating the extended client.

This does not create mutliple prisma clients (new PrismaClient(...)) or multiple database connections per tenant or per request, as extended clients share the main client's connection. From the Prisma docs,

An extended client is a lightweight variant of the standard Prisma Client that is wrapped by one or more extensions. The standard client is not mutated. You can add as many extended clients as you want to your project.
....

  • Each extended client operates independently in an isolated instance.
  • Extended clients cannot conflict with each other, or with the standard client.
  • All extended clients and the standard client communicate with the same Prisma query engine.
  • All extended clients and the standard client share the same connection pool.

If the extended client provider is NOT request-scoped it will should only create a single extended client that will be used for the lifetime of the server process. If the useFactory provider becomes request-scoped in your particular application (somehow), the useFactory function will run on every request. However, this isn't really a problem (extended prisma clients are meant to be disposable).

This single extended prisma client works for all tenants through the use of AsyncLocalStorage, which is facilitated by nestjs-cls, to create a unique context for each request. From the Node docs:

These classes are used to associate state and propagate it throughout callbacks and promise chains. They allow storing data throughout the lifetime of a web request or any other asynchronous duration. It is similar to thread-local storage in other languages.

Basically, the nestjs-cls middleware wraps the asynchronous request, giving each request its own unique state or store. When the middleware initializes (in the bootstrap function), the tenant_id is pulled off the request and inserted in the store (The complete example repo adds the un-encrypted Iron-Session session to the store, which contains the tenant_id). This store can then be referenced (using the ClsService provider) anywhere in the app, without making the given provider it's injected into request-scoped (which includes the aforementioned Custom Factory Provider that creates the extended Prisma client)

More simply, AsyncLocalStorage lets us initialize and reference state that is scoped to each request without affecting the injection scope of providers which reference that state, which is useful because the Request Injection Scope can negatively impact performance.

End result: we can do multi-tenant stuff in NestJS with Prisma without needing to instantiate multiple PrismaClient instances (or create multiple database connections), which means this solution is scalable, as it doesn't incur the performance penalties which come with request-scoped providers, and there's no risk of hitting the PostgreSQL unique connection cap, which defaults to 100.

You can verify that this is the case with the example repo by looking at the logs for the backend service and localhost/nest/stats, after logging in and out as different users.

Other docs:
Nestjs Factory Providers: docs.nestjs.com/fundamentals/custo...
Prisma Client extensions: prisma.io/docs/concepts/components...

Collapse
 
mahee profile image
Mahee Gamage

Thanks man for the detailed explanation. Since prisma client extensions are still in preview stage, I went with using prisma middleware to add the tenant filter. Still WIP. Will add a comment in here when fully completed

Thread Thread
 
moofoo profile image
Nathan Cook

You're welcome, good luck!

If you're going the middleware route, I strongly recommend load testing your application in a way that simulates multiple tenants sending requests at the same time. In my personal experience (after working through several multi-tenant solutions), certain approaches will seemingly work fine during development, but will have obvious issues in a real-world (or approximate) scenario.

For doing the tests, I like to use browser automation (with Playwright), primarily because it tests the auth flow in full. Typically I'll set up a GET endpoint that's only available when NODE_ENV=test that dumps the data I want to check (to make sure tenancy is working correctly, etc).