Subdomain driven — Schema Isolated Multi Tenancy using NestJs

Godfrey Odenigbo
9 min readDec 29, 2023

--

Abstract

Multi-tenancy, the architectural approach allowing a single application instance to efficiently serve multiple tenants (customers), has become increasingly popular for its cost-efficiency and scalability. This article explores the implementation of subdomain-driven, schema-based multi-tenancy in a NodeJS environment, using NestJs, PostgreSQL and Sequelize. We delve into the intricacies of this approach, discussing its implementation, advantages, and potential drawbacks.

Subdomain-driven schema-based multi-tenancy using NestJs presents a powerful and flexible solution suitable for a wide range of applications. If you’re considering building a flexible and scalable application, particularly one involving multiple customer onboarding processes, multi-tenancy in your Nestjs application is a compelling choice. It offers several benefits, including data isolation and cost-effectiveness.

Table of Contents
1. Introduction
2. A Brief History of Multi-tenant Applications
3. Popular Multi-tenancy Types
4. Code Setup
5. Pros and Cons of Multi-Tenancy
6. Conclusion

Introduction

In this article, we’ll explore subdomain-driven, schema-based multi-tenancy using Nestjs + sequelize + PostgreSQL. I’ve have also explored this concept in the realm of .NET Core 8 in my article “Subdomain driven — Schema Isolated Multi Tenancy using Dotnet Core”.
Multi-tenancy is an architectural approach that enables a single application instance to serve multiple customers. We’ll delve into the intricacies of this approach, its implementation, and its pros and cons. There are many different ways to implement multi-tenancy. One of such approaches is to use a schema-based multi-tenancy. In this approach, each tenant is assigned its own schema within the same database. This allows the data for each tenant to be isolated from the data for other tenants.

In this article, we will discuss how to implement subdomain-driven schema isolated multi-tenancy using NestJs, PostgreSQL and Sequelize.

Brief

The concept of multi-tenancy has been around for many years. In the early days of computing, multi-tenancy was often used to share mainframes between multiple organizations. As computing power became more affordable, multi-tenancy became less common. However, in recent years, multi-tenancy has become increasingly popular due to the growing popularity of cloud computing.

Popular Multi-tenancy Types

Database level multi-tenancy isolation: In this approach, each tenant is assigned its own database. This is the most secure type of multi-tenancy, as it completely isolates the data for each tenant from the data for other tenants. However, it can also be the most expensive type of multi-tenancy, as it requires a separate database for each tenant.

Database level multi-tenancy isolation

Schema level multi-tenancy isolation: In the schema level multi-tenancy isolation approach, each tenant (Customer/Company) has a separate schema within a shared database. This separation simplifies data management and allows for easier scaling (More on this topic is covered in my article referenced earlier).

image show high level schema isolated multi-tenancy

Row Level Multi-tenancy Isolation: In this approach, each tenant’s data is isolated from other tenants at the row level. This means that each tenant can only see and access their own data, even though all of the data is stored in the same database and in the same tables. To achieve this, each query to the database would include a condition(s) based on the current tenant.

Row Level Multi-tenancy Isolation

Code Setup

Our code setup would be broken into to major parts:

  • Database and Migrations
  • Application Logic

Database and Migrations

Firstly, we need to make our sequelize migrations “schema aware” i.e it should apply migrations at a schema level. How do we go about this? In my implementation which we would discuss below, I took the approach of keeping a record of all tenants created in the public schema table titled “Tenant”.

tenant table in the public schema

The public schema was selected as it’s the default schema of PostgreSQL and we would also be making use of it as our shared data source across tenants (for things like system wide settings etc). Next, I created a module (TenantProviderService) which handles tenant related activities which I would discuss in this article.

Public and Tenant Repository Instances: To be able to perform db operations on either the public schema or a custom schema, the inherited getter methods “publicRepoInstance” or “tenantRepoInstance” can be used to access the repository instances pointing to the respective schema. We can achieve that as shown below

export class TenantProviderService<T extends Model<T, T>> {
constructor(
protected readonly repository: BaseInterfaceRepository<T>,
protected readonly entityModel: ModelCtor<T>,
) {
this.registerEntity(entityModel);
}

protected get tenantRepoInstance() {
return this.fetchTenantRepoInstance();
}

protected get publicRepoInstance() {
return this.fetchPublicRepoInstance();
}

private fetchTenantRepoInstance = () => {
const request: Request = RequestContext.currentContext.req;
if (!TenantProviderService.entities[this.entityModel.name])
throw new Error(
'Model not auto-registered. You can register manually it by calling the registerEntity(entity: ModelCtor) method.',
);
return this.repository
.getRepo(this.entityModel)
.schema(request.headers['subdomain'].toString());
};
private fetchPublicRepoInstance = () => {
return this.repository.getRepo(this.entityModel).schema('public');
};

With the RequestContext class, we can retrieve the subdomain from the request headers. The RequestContext is imported from the npm package ‘nestjs-request-context’ gives us access to the request information from a non request-scoped service.
I added it to the AppModule as shown below

imports: [RequestContextModule]

Creating new schemas: Next, to create a new schema, in our TenantProviderService class, we can make use of the publicRepoInstance getter method to create a new schema at runtime as shown below:

export class TenantProviderService<T extends Model<T, T>> {
constructor(
protected readonly repository: BaseInterfaceRepository<T>,
protected readonly entityModel: ModelCtor<T>,
) {
this.registerEntity(entityModel);
}

protected get tenantRepoInstance() {
return this.fetchTenantRepoInstance();
}

protected get publicRepoInstance() {
return this.fetchPublicRepoInstance();
}

protected async createSchema(schemaName: string) {
try {
// check if schema exist
let schemaExist = await this.getSchema(schemaName);
if (schemaExist.length == 0) {
// schema doesnt exist
await this.publicRepoInstance.sequelize
.getQueryInterface()
.createSchema(schemaName);
schemaExist = await this.getSchema(schemaName);
}
return schemaExist != undefined && schemaExist != null;
} catch (e) {
console.error(e);
}
return false;
}

Applying migrations to schema: To apply migrations on a schema, my approach was quite simple.
I registered all my models in the constructor by calling the registerEntity function, which gathers all the models in a private JS object (called entities) in the TenantProviderService class. With this object, we have a key value mapping between the sequelize model and it’s model name which we can access at a later time to make use of while applying migrations (e.g on-boarding of a new tenant)

type EntityModelMap = {
[key: string]: ModelCtor<any>;
};

export class TenantProviderService<T extends Model<T, T>> {
constructor(
protected readonly repository: BaseInterfaceRepository<T>,
protected readonly entityModel: ModelCtor<T>,
) {
this.registerEntity(entityModel);
}

private static entities: EntityModelMap = {};

protected async registerEntity(entity: ModelCtor) {
if (!TenantProviderService.entities[entity.name]) {
TenantProviderService.entities[entity.name] = entity;
console.log('registering module', entity.name);
}
}

protected async applyMigrationsToTenant(schema: string) {
try {
// apply migration to migrate registered entities
Object.values(TenantProviderService.entities).forEach((entity) => {
this.repository.getRepo(entity).schema(schema).sync({ alter: true });
});
return true;
} catch (e) {
console.error(e);
}
return false;
}

Deleting Schema: To remove a tenant, my approach is quite similar to the one I took in while working on the .net core 8 multi tenancy skeleton. The approach involves dropping all the db constraints in the supplied schema (foreign keys, indexes etc), dropping all the tables in the schema, dropping the schema itself and finally removing the record from our “Tenant” table on the public schema thereby completely removing the tenant from the application.

Application Logic

Tenant provider service: The tenant provider service (which I’ve shown earlier in the Database and Migrations section) acts as a parent class which other modules (services) can inherit from. It provides the necessary features to have a “schema-aware” module. Once a class inherits from the tenant provider service, it can perform functionalities like applying migrations, creating schemas, accessing either the public or tenant db repository instance etc.

@Injectable()
export class UserService extends TenantProviderService<User> {
constructor(
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
@Inject('UserRepositoryInterface')
readonly repository: UserRepositoryInterface,
) {
super(repository, User);
}
}

async createUser(payload: UserDTO): Promise<ResponseDTO<User>> {
const response = new ResponseDTO<User>();
try {
const user = await this.tenantRepoInstance.create(payload);
response.data = user;
response.status = true;
response.message = 'User account created.';
response.code = RESPONSE_CODE._201;
} catch (e) {
const errorObject: ErrorClass<UserDTO> = {
payload,
error: e['errors'],
response: null,
};
response.message = 'Something went wrong, please try again.';
response.code = RESPONSE_CODE._500;
if (typeof e === 'object') {
if (e['name'] === 'SequelizeUniqueConstraintError') {
response.message =
'Please ensure email or phone has not been used to open an existing account.';
response.code = RESPONSE_CODE._409;
errorObject.error = e['parent'];
}
}
errorObject.response = response;
this.logger.error(e.toString(), errorObject);
}
return response;
}

Tenant Middleware: The Tenant service provider also offers a middleware that needs to be utilized, this middle ware handles extracting and saving the subdomain from the request for use in the system etc.

export class TenantProviderMiddleware implements NestMiddleware {
use(req, res, next) {
const subdomain = this.extractSubdomain(req.hostname);
this.rootDomain = process.env.ROOT_DOMAIN;
this.tenantService
.getTenant(subdomain)
.then((result) => {
if (!result.status) {
return res.status(404).send(
new ResponseDTO({
code: '404',
message: 'Tenant not found',
status: false,
}),
);
}
req.headers.subdomain = processSubdomain(subdomain);
next();
})
.catch((err) => {
console.error('err', err);
return res.status(500).send(
new ResponseDTO({
code: '500',
message: 'Something went wrong, please try again.',
status: false,
}),
);
});
}

rootDomain: string = '';

extractSubdomain(url: string) {
// e.g https://localhost.xyz.com:3000
url = url.replace('http://', '').replace('https://', '');
// e.g localhost.xyz.com:3000
url = url.split(':')[0];
// e.g localhost.xyz.com = [localhost, xyz, com]
let subdomain = url.split('.')[0];
// e.g localhost
subdomain = this.prepareSubdomain(subdomain);
return subdomain;
}

prepareSubdomain(subdomain: string) {
subdomain = subdomain.replace('-', '_');
return subdomain;
}

processSubdomain(subdomain: string) {
if (
subdomain == 'api' ||
subdomain == 'admin' ||
subdomain == this.rootDomain
) {
// public domains, which make use of the public schema by default
return 'public';
}
return subdomain;
}
}

We then apply the TenantProviderMiddleware to all the routes, so the TenantProviderMiddleware will be executed for all incoming requests as shown below:

export class AppModule {
configure(app: MiddlewareConsumer) {
app.apply(TenantProviderMiddleware).forRoutes('*');
}
}

Starting Up

Before we run the application, we have a couple more changes to make.

etc/hosts: To map a custom domain to a local IP address (for local development, as on production, this won’t be necessary as you would already have a domain name), you need to add an entry to the /etc/hosts (for unix users, for windows users, please check this article out) that maps the domain name to the IP address. For example, to map the domain name xyz.com to the IP address 127.0.0.1, you would add the following line to the /etc/hosts file

127.0.0.1 xyz.com
My local etc/hosts file

With the above we should be able to run the application on the custom domain we set up locally.

Pros and Cons of Multi-Tenancy

Pros

  • Cost-effectiveness: Multi-tenancy can be a cost-effective way to deploy and manage applications, as it eliminates the need to create and maintain separate instances of the application for each tenant.
  • Scalability: Multi-tenancy can efficiently accommodate new customers.
  • Easy Maintenance: Single code base simplifies updates and bug fixes.

Cons

  • Security: Multi-tenancy can introduce security risks, as tenants may be able to access each other’s data if they have the wrong permissions.
  • Complexity: Multi-tenancy can be complex to implement and manage, as it requires careful planning and design.
  • Performance: Multi-tenancy can impact performance, as tenants may compete for resources such as CPU, memory, and storage.

Conclusion

In this article, we discussed how to implement subdomain-driven schema-based multi-tenancy using Nestjs. Multi-tenancy is a powerful software architecture pattern that can offer a number of advantages for businesses of all sizes.

Based on the simple example we’ve discussed, I think you can start creating fantastic applications with a bit of fine-tuning and additional features as needed.

Github Repository for the code can be accessed here

--

--

Godfrey Odenigbo

Full Stack Engineer | NodeJs | .Net | Containers | Mongo | Postgres