DEV Community

Murilo Gervasio
Murilo Gervasio

Posted on

How to make Multi-tenant applications with NestJS and Prisma 🚀.

In this article we are going to build a multi tenant application using NestJS and Prisma.
We will be using PostgreSQL as our database and we will be using Prisma to interact with the database.
At the end our Prisma service will be going to automatic make the filters based on tenant access level.

There are several ways to implement multi-tenancy in an application. The most common ways are:

  • Database per tenant: Each tenant has its own database. This is the most isolated approach, but it can be expensive and hard to maintain.
  • Schema per tenant: Each tenant has its own schema in the database. This is a good balance between isolation and cost.
  • Shared database, shared schema: All tenants share the same database and schema. This is the most cost-effective approach, but it requires more complex logic to separate the data of different tenants.

The way we are going to implement multi-tenancy in this article is by using the Shared database, shared schema approach. We will add a tenant_id column to each table in the database to associate each record with a tenant. We will then use this column to filter the data based on the tenant that is making the request. the column is called discriminator.

First things first!

before we start lets start a new NestJS project and install the required dependencies.

$ npm i @nestjs/cli
$ nest new multi-tenant-app
$ cd multi-tenant-app
$ npm i -g prisma
$ prisma init 
Enter fullscreen mode Exit fullscreen mode

Setting up Prisma

Now were are going to define some models to exemplify our Application. The following models will be used posts and tenants.

model Post {
  id        Int     @id @default(autoincrement())
  title     String
  content   String?
  tenant_id Int
  tenant    Tenant  @relation(fields: [tenant_id], references: [id])

  @@map("posts")
}

model Tenant {
  id   Int    @id @default(autoincrement())
  name String
  Post Post[]

  @@map("tenants")
}
Enter fullscreen mode Exit fullscreen mode

At the end you can just run the following command to generate the Prisma client.

$ prisma generate
Enter fullscreen mode Exit fullscreen mode

Now you can just define a simple prisma service and a prisma module and import it at your AppModule just as NestJS documentation.

import { Global, Injectable, Module, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  constructor() {
    super();
  }

  async onModuleInit(): Promise<void> {
    await this.$connect();
  }
}

@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

Enter fullscreen mode Exit fullscreen mode

Defining our post module

Now you can just define a simple post module with a get all posts endpoint.

import { Controller, Get } from '@nestjs/common';
import { PostService } from './post.service';

@Controller('posts')
export class PostController {
  constructor(private readonly postService: PostService) {}

  @Get()
  findAll() {
    return this.postService.findAll();
  }
}
Enter fullscreen mode Exit fullscreen mode
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../infrastructure/prisma.service';

@Injectable()
export class PostService {
  constructor(private readonly prisma: PrismaService) {}

  findAll() {
    return this.prisma.post.findMany();
  }
}
Enter fullscreen mode Exit fullscreen mode

NestJS CLS module

Now we are going to use the NestJS CLS module to handle the tenant_id at our requests. The CLS module is a middleware that allows you to store data in a context that is shared across all the functions that are executed in the same request. This is useful for storing data that is specific to a request, such as the tenant_id.

$ npm i nestjs-cls
Enter fullscreen mode Exit fullscreen mode

After that you can just set up the CLS module at your AppModule.

@Module({
  imports: [
    PrismaModule,
    PostModule,
    ClsModule.forRoot({
      middleware: {
        mount: true,
        setup: (cls, req) => {
          cls.set('tenantId', req.headers['x-tenant-id']);
        },
      },
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

By doing this we are going to set the tenant_id in the CLS context for each request. Now we can just use this tenantId to filter the data based on the tenant that is making the request.

Now we can just prevent the user from making a request without the tenant_id or to access data he does not have access by creating a guard.

@Injectable()
export class TenantGuard implements CanActivate {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const tenantId = context.switchToHttp().getRequest().headers['x-tenant-id'];

    if (!tenantId) {
      throw new ForbiddenException('Tenant ID is required');
    }

    const userHasAccess = true; // Check if the user has access to the tenant

    return userHasAccess;
  }
}
Enter fullscreen mode Exit fullscreen mode

Prisma automatic filters by request tenant.

Now we are defining a new prisma service that is going to filter the data based on the tenant_id that is in the CLS context.

To do this we are going to use the prisma.$extends method to add a middleware that is going to filter the data based on the tenant_id.

export function prismaTenantFactory(
  prisma: PrismaService,
  tenant_id: number | null,
): PrismaService {
  return prisma.$extends({
    query: {
      $allModels: {
        findMany: function ({ query, args }) {
          args.where = {
            ...args.where,
            tenant_id,
          } as unknown;

          return query(args);
        },
        findFirst({ query, args }) {
          args.where = {
            ...args.where,
            tenant_id,
          } as unknown;

          return query(args);
        },
        findUnique({ query, args }) {
          args.where = {
            ...args.where,
            tenant_id,
          } as any;

          return query(args);
        },
        findUniqueOrThrow({ query, args }) {
          args.where = {
            ...args.where,
            tenant_id,
          } as any;

          return query(args);
        },
        findFirstOrThrow({ query, args }) {
          args.where = {
            ...args.where,
            tenant_id,
          } as unknown;

          return query(args);
        },
        updateMany({ args, query }) {
          args.where = {
            ...args.where,
            tenant_id,
          } as unknown;

          return query(args);
        },

        deleteMany({ args, query }) {
          args.where = {
            ...args.where,
            tenant_id,
          } as unknown;

          return query(args);
        },

        create({ query, args }) {
          args.data = {
            ...args.data,
            tenant_id: tenant_id,
          } as any;

          return query(args);
        },
        upsert({ args, query }) {
          args.where = {
            ...args.where,
            tenant_id: tenant_id,
          } as any;

          args.create = {
            ...args.create,
            tenant_id: tenant_id,
          } as any;

          return query(args);
        },

        createMany({ query, args }) {
          if (Array.isArray(args.data)) {
            args.data = args.data.map(
              (d) =>
                ({
                  ...d,
                  tenant_id: tenant_id,
                }) as any,
            );
          } else {
            args.data = {
              ...args.data,
              tenant_id: tenant_id,
            } as any;
          }

          return query(args);
        },
      },
    },
  }) as PrismaService;
}
Enter fullscreen mode Exit fullscreen mode

By doing this we define a factory that will extend our prisma client, adding a middleware that will filter the data based on the tenant_id that is in the CLS context. Not only that it will automatically insert the tenant_id from the user request to new rows.

This will be our updated prisma service calling the factory.

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  constructor(private readonly cls: ClsService) {
    super({
      log: ['query'],
    });
  }

  async onModuleInit(): Promise<void> {
    await this.$connect();
  }

  get instance(): PrismaService {
    const tenantId = this.cls.get('tenantId');
    return prismaTenantFactory(this, tenantId);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now we can just use the instance property of the prisma service to get a new prisma client that will filter the using the current tenant_id ind the request.

@Injectable()
export class PostService {
  constructor(private readonly prisma: PrismaService) {}

  findAll() {
    return this.prisma.instance.post.findMany();
  }
}
Enter fullscreen mode Exit fullscreen mode

Now when we send a Get request to the /posts endpoint, the data will be filtered based on the tenantId that is in the CLS context.

Now we will send the bellow request to the NestJS application.

$ curl -X GET http://localhost:3000/posts -H "x-tenant-id: 3"
Enter fullscreen mode Exit fullscreen mode

The prisma provider will create a proxy of the prisma instance and will generate the query bellow to the database.

SELECT "public"."posts"."id", "public"."posts"."title",
"public"."posts"."content", "public"."posts"."tenant_id" FROM 
"public"."posts" WHERE "public"."posts"."tenant_id" = $1 OFFSET $2
Enter fullscreen mode Exit fullscreen mode

You can find the full code in the GitHub repository.
Now Happy coding! 🚀

Top comments (4)

Collapse
 
andrewbaisden profile image
Andrew Baisden

Prisma is the GOAT of ORMs, in my opinion. Use it in most projects that require a database.

Collapse
 
murilogervasio profile image
Murilo Gervasio

Had some bad experiences with other typescript ORMs, prisma is really disruptive.

Collapse
 
esdrassantosdv profile image
EsdrasSantosDV

GOOD

Collapse
 
carlos_henriqueribeirod profile image
Carlos Henrique Ribeiro dos Reis

Nice 😉