Building a Multi-Tenant Application with NestJS: An In-Depth Guide
In today’s fast-paced technological landscape, multi-tenant architecture has become a mainstream approach, especially for SaaS applications. Allowing multiple tenants to share the same application and database with complete data isolation and customization per tenant can lead to efficient resource usage and cost savings. In this guide, we’ll walk through the process of building a multi-tenant application using NestJS, a progressive Node.js framework for building efficient, reliable, and scalable server-side applications.
Understanding Multi-Tenancy
Before diving into code, let’s clarify what multi-tenancy is. In a multi-tenant architecture, a single instance of an application serves multiple customers (tenants). Each tenant’s data is isolated and customized to their needs. There are generally three approaches to data isolation in multi-tenant systems:
- Shared Database, Shared Schema: All tenants use the same database and tables. Differentiation is managed using a tenant identifier.
- Shared Database, Separate Schemas: Tenants share a database but have separate schemas, allowing for data isolation.
- Separate Databases: Each tenant has their own database. This offers the highest data isolation but can be more complex to manage.
For this article, we’ll explore the “Shared Database, Separate Schemas” approach using NestJS.
Setting Up NestJS
First, let’s create our NestJS project. If you haven’t installed the NestJS CLI, do so by running:
npm i -g @nestjs/cli
Now, create a new project:
nestjs new multi-tenant-app
cd multi-tenant-app
Install necessary packages for PostgreSQL, which we’ll use as our database:
npm install @nestjs/typeorm typeorm pg
Configuring Multi-Tenancy
Config Service
Firstly, let’s set up different configurations for connecting to our databases. We’ll create a database.config.ts
file.
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { ConnectionOptions } from 'typeorm';
// You can load these configurations using environment variables or a dynamic configuration service.
export const getTenantConfig = (tenantId: string): TypeOrmModuleOptions => ({
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'your-username',
password: 'your-password',
schema: tenantId, // Use tenantId as schema
database: 'multi_tenant_db', // Database name
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
synchronize: true,
});
Tenant Middleware
We’ll also need middleware to determine the tenant based on the request. Let’s create tenant.middleware.ts
. This middleware could use a subdomain or a header to determine the tenant.
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class TenantMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const tenantId = req.headers['x-tenant-id'] as string; // Assume tenant ID comes from a custom header
if (!tenantId) {
throw new Error('Tenant ID is missing');
}
// Attach tenant ID to the request for later use
req['tenantId'] = tenantId;
next();
}
}
Make sure to apply this middleware in your app.module.ts
:
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { TenantMiddleware } from './tenant.middleware';
import { TypeOrmModule } from '@nestjs/typeorm';
import { getTenantConfig } from './config/database.config';
@Module({
imports: [
TypeOrmModule.forRootAsync({
// Dynamic module for TypeORM based on incoming requests
useFactory: (req: any) => {
const tenantId = req['tenantId'];
return getTenantConfig(tenantId);
}
}),
],
controllers: [],
providers: [],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
// Apply the tenant middleware
consumer.apply(TenantMiddleware).forRoutes('*');
}
}
Creating Tenants
With this schema-based approach, you’ll need to create a schema for each new tenant. This could be done with a CLI script or through admin functionality in your application:
import { Connection, EntityManager } from 'typeorm';
@Service()
export class TenantService {
constructor(private connection: Connection) {}
async createTenantSchema(tenantId: string): Promise<void> {
const queryRunner = this.connection.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS "${tenantId}"`);
await queryRunner.commitTransaction();
} catch (err) {
await queryRunner.rollbackTransaction();
} finally {
await queryRunner.release();
}
}
}
Handling Tenant Requests
Once the middleware attaches the tenant ID to incoming requests, you can use this ID to tailor the response to the respective tenant. When extending the application with services and controllers, always use the provided tenant context for data operations.
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User) private userRepository: Repository<User>,
) {}
findAll(tenantId: string): Promise<User[]> {
// Assume user entities have a tenantId field
return this.userRepository.find({ where: { tenantId } });
}
}
Conclusion
Building a multi-tenant application with NestJS involves a good understanding of request handling, database management, and data isolation strategies. With the setup shown above, you can start creating a robust multi-tenant application that effectively manages tenants, all while leveraging the power of NestJS and TypeORM. The “Shared Database, Separate Schemas” approach provides a balanced trade-off between performance, cost, and complexity. As the architecture grows more complex, consider implementing advanced techniques such as caching, partitioning, and load balancing to maintain high performance.