Multi-tenancy (Schema separated) with NestJS, Knex and PostgreSQL in SAP BTP

October 1, 2022 ☼ NestJSPostgreSQLNode.js

In many Software-as-a-Service offerings, choosing between single-tenant and multi-tenant model is an important decision that has repercussion on many aspects such as costs, performance, scalability and development complexity.

If you are going with a multi-tenant model one essential decision you’ll have to make is how to partition data for each tenant of your system.

Tenant isolation, performance, noisy neighbour effect, pricing strategy, and a range of other factors will influence the design choices you make.

Where you deploying your application has also a big impact on which strategy and design choice you will have to make. All hyperscalers are not created equal.

The following code has been implemented in the context of SAP BTP platform with CloudFoundry as a runtime.

SAP BTP provides a multi-tenant functionality that allows application providers to own, deploy, and operate tenant-aware applications for multiple consumers, with reduced costs.

You can find more information here.

What is multi-tenancy by the way?

Software multi-tenancy is a software architecture in which a single instance of software runs on a server and serves multiple tenants. Systems designed in such manner are shared” (rather than dedicated” or isolated”). A tenant is a group of users who share a common access with specific privileges to the software instance.

Source 👆

⚠️ Important Disclaimer ⚠️

This article will oversimplify some of the concepts and challenges. The aim here is to give you enough food for thoughts that hopefully will set you on the right path. I personally would love to discuss how you are approaching a similar scenario. If you want to exchange notes, just hit me up on Twitter or send me an email.

Tech Stack

Some assumptions first

There are 3 common data partitioning models used in multi-tenant systems: silo, bridge, and pool.

Please refer to this AWS article to find out more about the difference.

We’ve chosen to use a bridge approach: share the same database instance but use a different schema for each tenant.

This will keep the complexity relatively low (infra as well), add tenants on the go as needs arise and allow us to off-board them easily.

We’ve been working with only 1 large instance of a managed PostgreSQL.

Our application being tenant-aware is able to:

Our app doesn’t perform cross-tenant query.

How to achieve Data Separation

As I said in the previous section we’re going to create 1 schema per each Tenant. All of them will share the same Table structures and Views.

In our code we will enforce the separation only via setting the TenantId correctly before executing any query.

Eg.

SET search_path TO 'tenant-abc-1234';

We could increase the level of isolation by creating a different user per schema and assign it exclusively to the schema.

Eg.

GRANT USAGE ON SCHEMA 'tenant-abc-1234' TO 'user-abc-1234';

The problem with this approach is that we can’t make use of the same Connection Pooling. We will have to change the connection string to use the schema. Hence, multiple tenants, multiple connection Pools. This will end up in performance issues.

If we have many clients handling tenant based requests we will end up with exhausting the max_connections in PostgreSQL:

org.postgresql.util.PSQLException: FATAL: sorry, too many clients already.

If you have just a few tenants you don’t have to worry about all of the above. Assuming you do the math right when setting your Connection Pool. :)

This needs to be a conscious decision anyway.

I believe you could go around this issues using a connection pool manager like PgBouncer. I have not tested it though.

How to do it in NestJS

First thing you have to do is to hook up Knex.

  1. Create database.module.ts
const databasePoolFactory = async (
  configService: ConfigService<AppConfig, true>,
) => {
  return knex({
    client: 'pg',
    connection: {
      connectionString: configService.get('database', { infer: true }).config.credentials.uri
    }
    pool: {
      min: 2,
      max: 50, // Changed this based on your ENV
    },
  });
};

@Global()
@Module({
  providers: [
    {
      provide: KNEX_CONNECTION,
      inject: [ConfigService],
      useFactory: databasePoolFactory,
    },
    PostgresClient,
  ],
  exports: [
    PostgresClient,
    KNEX_CONNECTION,
  ],
})
export class DatabaseModule implements OnApplicationShutdown {
  constructor(
    private readonly moduleRef: ModuleRef,
  ) {}

  // To make it work you need to enable ShutDownHooks in main.ts
  onApplicationShutdown(signal?: string) {
    this.logger.log(`Shutting down on signal ${signal}`);
    const pool = this.moduleRef.get(KNEX_CONNECTION) as Knex;
    return pool.destroy();
  }
}
//main.ts

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.enableShutdownHooks();
}
  1. Create a Postgres client: PostgresClient.ts
@Injectable()
export class PostgresClient implements OnModuleDestroy {
  constructor(
    @Inject(KNEX_CONNECTION) private pool: Knex,
    @Inject(LOGGER) private readonly logger: Logger
  ) {
    this.logger.setContext(PostgresClient.name);
  }
  async onModuleDestroy() {
    this.logger.log("Destroying PostgresClient");
    await this.pool.destroy();
  }

  getPool() {
    return this.pool;
  }

  /**
   * @remarks
   * To be used only in TESTING environment
   **/
  async cleanDatabase(schemaName: string) {
    if (process.env.NODE_ENV === "production") return;
    const tablenames = await this.pool
      .queryBuilder()
      .select()
      .from("pg_tables")
      .where("schemaname", "=", schemaName);

    for (const { tablename } of tablenames) {
      try {
        await this.pool.schema.withSchema(schemaName).dropTable(tablename);
      } catch (error) {
        console.error({ error });
      }
    }
  }
}

How to scope queries and communicate the tenant ID to downstream consumers

When a request hits the server we need to be able to extract the tenant-id and use it to scope the query:

// Simplified version of a real implementation

@Injectable()
export class IdentityPersistenceImpl implements IdentityPersistence {
  constructor(
    private readonly postgresClient: PostgresClient,
    @Inject(LOGGER) private readonly logger: Logger
  ) {
    this.logger.setContext(IdentityPersistenceImpl.name);
  }

  async getCompanyIdentity(id: string): Promise<CompanyIdentity> {
    const companyIdentity = await this.postgresClient
      .getPool()("identity")
      .withSchema("<tenant-id>") // Where do we get it and how to pass it here?
      .where("id", id)
      .first();

    if (!companyIdentity) {
      throw new NotFoundException("Company Identity not found");
    }

    return this.toDomainEntity(companyIdentity);
  }
}

A common approach to get it done is to use a middleware, which checks incoming requests for presence of a specific header or in the JWT and adds the tenantId attribute to Express requests.

You can then take it and pass it down to services and repositories. If you google around a bit you will find out that this solution is implemented with a factory provider that is REQUEST scoped. See for example this blog post. This has unfortunate performance implications since all downstream consumers will also be request-scoped by default.

To be request scoped is necessary if you want to read the correct ID per request.

How do we solve it?

Async Local Storage to the rescue.

You can read a bit more about it here but in essence it allows to create some sort of global” variable and assign your special data to it. Then, when another request comes from the same user, you can use the global variable to read whatever you had stored earlier without worrying about messing up users.

Please refer to the Node.js official documentation

We’re gonna implementing it inside a NestInterceptor (docs)

Create async-context.interceptor.ts:

import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from "@nestjs/common";
import { AsyncContext } from "@nestjs-steroids/async-context";
import { Observable } from "rxjs";
import { Request } from "express";

@Injectable()
export class AsyncContextInterceptor implements NestInterceptor {
  constructor(private readonly ac: AsyncContext<string, string>) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    this.ac.register(); // Important to call .register or .registerCallback (good for middleware)
    const req = context.switchToHttp().getRequest<Request>();

    if (req.path.includes("/health/")) {
      return next.handle();
    }

    const tenantId = req.extractTenantId(); // Implement this function based on your use case
    if (tenantId) {
      this.ac.set("tenantId", tenantId);
    }
    return next.handle();
  }

  /**
   * @remarks
   *
   * Used mainly for testing purpose
   *
   */
  getAsyncContext(): AsyncContext<string, string> {
    return this.ac;
  }
}

Add it to providers in the app.module.ts:

providers: [
  //... other services
  {
    provide: APP_INTERCEPTOR,
    useClass: AsyncContextInterceptor,
  },
];

Scoping the queries

Let’s put it all together now:

// Simplified version of a real implementation

@Injectable()
export class IdentityPersistenceImpl implements IdentityPersistence {
  constructor(
    private readonly asyncContext: AsyncContext<string, string>,
    private readonly postgresClient: PostgresClient,
    @Inject(LOGGER) private readonly logger: Logger
  ) {
    this.logger.setContext(IdentityPersistenceImpl.name);
  }

  async getCompanyIdentity(id: string): Promise<CompanyIdentity> {
    const tenantId = this.asyncContext.get("tenantId"); // Getting the Tenant ID by leveraging AsyncLocalStorage

    const companyIdentity = await this.postgresClient
      .getPool()("identity")
      .withSchema(tenantId)
      .where("id", id)
      .first();

    if (!companyIdentity) {
      throw new NotFoundException("Company Identity not found");
    }

    return this.toDomainEntity(companyIdentity);
  }
}

Closing thoughts and Remarks

The solution above provides good developer experience and does provide overall good performance.

Of course there are many other things that needs to be taken care of in the above setup:

If interested will post more content on the above topics.


I hope you’ve found some of these information useful. This is a complex topic that could fill an entire book. There are not much content out there (if you know some please share) since, I believe, it’s very use case specific and hard to fit in a relatively small blog post.

That’s all folks!


If you have any suggestions, questions, corrections or if you want to add anything please DM or tweet me: @zanonnicola