Skip to main content

Command Palette

Search for a command to run...

Schema-Based Multi-Tenancy with NestJS and Prisma

Updated
6 min read
Schema-Based Multi-Tenancy with NestJS and Prisma

We recently migrated a big NestJS project to adopt Prisma as the ORM. It turned out to be a great decision, and I described our wonderful experience in a recent blog post.

The project is an API backend for a multi-tenant system, where each tenant has its own schema in our Postgres database.

The problem

In an ideal scenario, we would just need to specify the database schema for every performed query. Unfortunately, Prisma does not support that at the moment. However, it allows passing the database connection string when the PrismaClient is instantiated, skipping the one declared in the .env file. Thanks to that and the power of NestJS we came up with a quite solid solution.

The solution

We decided to dedicate a separate instance of PrismaClient to each tenant schema. Now, obviously, we cannot create an instance for every request, we better find a way to cache instances and reuse them when possible.

It's important to note that in our case we need to handle a relatively small amount of tenants, and we are also working with a database that can handle a large number of connections. This solution would not have been a good fit otherwise, since every instance of PrismaClient requires its own connection to the database.

The idea is to have a manager class that will hold all created instances, provide them, and will eventually create new ones when needed. It would also have to dispose every created instance on server shutdown to free up connections.

Let's now take a look at how we implemented this solution.

The implementation

The manager class is pretty straightforward. It is going to be a simple NestJS provider, holding a "cache" object for instantiated clients as a class property.

In our case tenant IDs are used as database schema names, but you might also provide your own mapping logic.

We need three methods here:

  1. getTenantId: this will extract the tenant id from the request object. In our case, it will be extracted from the JWT in the Authorization header, but it may also be deduced from the hostname.
  2. getClient: this will be called for every request to retrieve a tenant-dedicated PrismaClient instance, and it does most of the work.
  3. onModuleDestroy: this will be called by the framework on application shutdown, and it will be in charge of clients disposal.
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { Request } from 'express';

@Injectable()
export class PrismaClientManager implements OnModuleDestroy {
  // the client instances cache object
  private clients: { [key: string]: PrismaClient } = {};

  getTenantId(request: Request): string {
    // TODO: retrieve and return the tenant ID from the request object,
    // eventually throw an exception if the ID is not valid
  }

  getClient(request: Request): PrismaClient {
    const tenantId = this.getTenantId(request);
    let client = this.clients[tenantId];

    // create and cache a new client when needed
    if (!client) {
      const databaseUrl = process.env.DATABASE_URL!.replace('public', tenantId);

      client = new PrismaClient({
        datasources: {
          db: {
            url: databaseUrl,
          },
        },
      });

      // setup prisma middlewares if any

      this.clients[tenantId] = client;
    }

    return client;
  }

  async onModuleDestroy() {
    // wait for every cached instance to be disposed
    await Promise.all(
      Object.values(this.clients).map((client) => client.$disconnect()),
    );
  }
}

You may also want to implement type safety and validation for tenant IDs.

Note that the code above assumes the existence of a DATABASE_URL environment variable holding a database connection string with the schema query param set to public, like the one in the .env file generated by Prisma:

DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public"

Also, make sure to add this provider to a global module, like a DatabaseModule or the AppModule.

Now, the last thing is to find a way to call getClient for every request and pass around the instance based on where we need it.

First approach

The first, really verbose approach is to access the request object from every handler method in every controller and pass it around to every service method we need to call to handle the request.

// controller example method
  @Get()
  findAll(@Req() request: Request) {
    return this.service.findAll(request);
  }
export class MyService {
  constructor(private prismaClientManager: PrismaClientManager) {}

  findAll(request: Request) {
    const prisma = this.prismaClientManager.getClient(request);
    // method logic ...
  }
}

This is extremely verbose, we can definitely do better.

Second approach

Thanks to the NestJS dependency injection system, every registered provider is instantiated the first time it needs to be injected, and then the instance is reused any subsequent time. However, we can take advantage of a built-in mechanism to make a service request-scoped, asking the framework to create a new instance for each incoming request.

To do that we need to add a scope parameter to the Injectable decorator. This way we will also have access to the incoming request object.

import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
import { PrismaClientManager } from '../database/prisma-client-manager';

@Injectable({ scope: Scope.REQUEST })
export class MyService {
  private prisma: PrismaClient;

  constructor(
    @Inject(REQUEST) request: Request,
    prismaClientManager: PrismaClientManager,
  ) {
    this.prisma = prismaClientManager.getClient(request);
  }

  findAll() {
    // just use this.prisma to access the database
  }
}

This is less verbose and easier to manage than the first approach, but we still need to add the code above to all of the services from which we need to access the Prisma client.

Let's try to take advantage of dependency injection again to avoid that.

Third approach and final solution

Turns out all we need is a custom factory provider, which will be the only explicitly request-scoped component and will be called by the framework to provide us a PrismaClient whenever we need it to be injected as a service dependency.

// src/database/database.module.ts

import { FactoryProvider, Global, Module, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { PrismaClient } from '@prisma/client';
import { Request } from 'express';
import { PrismaClientManager } from './prisma-client-manager';

const prismaClientProvider: FactoryProvider<PrismaClient> = {
  provide: PrismaClient,
  scope: Scope.REQUEST,
  inject: [REQUEST, PrismaClientManager],
  useFactory: (request: Request, manager: PrismaClientManager) => manager.getClient(request),
};

@Global()
@Module({
  providers: [PrismaClientManager, prismaClientProvider],
  exports: [PrismaClient],
})
export class DatabaseModule {}

This way we can totally forget about the request-scoped logic and simply use the PrismaClient in our services as if it was a regular provider.

import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class MyService {
  constructor(private prisma: PrismaClient) {}

  findAll() {
    // just use this.prisma to access the database
  }
}

The providers with a dependency on the PrismaClient will still be request-scoped, but we don't need to make it explicit anymore, we can just use them as regular providers.

A complete example of the final implementation can be found in this repo.

This is how we achieved multi-tenancy with Prisma in a NestJS project. Please don't hesitate to ask any questions or leave a comment.

Note: the strategy described above is the same adopted by this great library. If you don't want to write your own implementation or you'd like to take advantage of other features such as a multi-tenant-aware migrations CLI, I highly suggest taking a look at it.

Cheers!

L

Dario Ielardi Thank you very much for this article. I have a project to develop that respects this architecture. I would like to know if the tenant databases must be created manually? if I have a frontent with angular for example, how to identify a tenant when creating an account from the front for example and save it in the right database?. A code example would help me more. Thanks in advance

A

Do you have any tips on how to manage migrations on multi-tenant schema

1
A

This is wonderfull!!!

D

Hi, I'm curious. Since you have a database per tenant, how do you make sure all those databases share the same structure and get all the latest migrations?

D

Hi, We have a non-breaking migrations policy and we migrate with a custom script to keep schemas in sync.

C

Dario Ielardi Could you give a brief example of how to synchronize tenants' databases synced?

A

Dario Ielardi How you synchronize the shemas? How you run your migrations?

Y

Very neat solution, congrats; I just want to point out a little detail (and please correct me if I'm wrong), you don't need to explicitly make prismaClientProvider request-scpoed, since you are injecting REQUEST into it, wich is in itself a request-scpoed provider, then his scope is bubbled up by the injection chain.

D

Hello, thank you for your kind words.

You're right, there's no need to explicitly make the custom provider request-scoped, I did that just to be totally explicit about the intended behavior.

1
J

Hi Dario,

thanks for the excellent write-up!

I was wondering, what alternative would you consider if you do have potentially thousands of tenants? I suppose you would then simply have to create the prisma connections based on incoming request?

D

Hi Jonathan, thanks for your kind words!

In a situation where the number of tenants is so big I'd probably avoid a schema-based architecture, and rather go with row-level data separation. That way I'd only need one database connection for one Prisma client.

1
J

Ah cool, indeed that's what we are currently "confined" to so to speak as well, nothing to do about it I suppose :). Thanks for your input on it!