Today we completed a rewrite of one of our apps and launched it to production. This was a migration from a single tenant Ruby on Rails + ReactJS to a multi-tenant MERN and I want to take this as an opportunity to share something which I hope some people will find relevant and useful.

What is a multi-tenant app? What are the benefits and pitfalls of building and deploying a multi-tenant app? This post does not intend to deal with these details. TL;DR a multi-tenant app lets you deploy a single codebase that can serve multiple customers and lets you optimise on infrastructure needed for serving the app. This has become a de-facto of modern cloud based SaaS apps.

If you have some background with Ruby on Rails, you might have come across a gem called apartment which is the base over which any RoR multi-tenant app is built. But what do you do if you are building an app in NodeJS? Is there a library equivalent for the apartment gem for NodeJS? Seeing that NodeJS + MongoDB is quite popular for modern web-app development, one would think that there would already be something stable and popular for this. Ah well, not really! Just like everyone else I tried finding some ready made solution for multi-tenancy in NodeJS with MongoDB but didn’t find anything good so I decided to build something from scratch (checkout the sample repo for the complete boilerplate code).

So let’s dive in and see what are the building blocks for a multi-tenant NodeJS + MongoDB app.

The app (and db) is divided into 2 categories: Tenant app and Admin app (the api routes are segregated as /admin and /tenant).

Admin app is used to manage tenant creation, storing and fetching tenant db connection information, etc.

Tenant app is the main app that each tenant will use and this is where you will write the core of your business logic like user management et al.

Each tenant will get its own db which gets initialised when a new tenant is created in the admin app. To keep the setup simple all the dbs (admin and tenants) will be hosted inside the same mongoDB instance with a connection pool and a common db connection manager will manage the db connection pool but this can scale out as per your architecture and preferences very easily.

admin and tenat db segregation

  • initAdminDbConnection: returns a db connection pool by connecting to the admin db URI.
const mongoose = require("mongoose");
mongoose.Promise = global.Promise;

const clientOption = {
  socketTimeoutMS: 30000,
  keepAlive: true,
  poolSize: 5,
  useNewUrlParser: true,
  useUnifiedTopology: true,
  useFindAndModify: false,
  useCreateIndex: true
};

// CONNECTION EVENTS
// When successfully connected
mongoose.connection.on("connected", () => {
  console.log("Mongoose default connection open");
});

// If the connection throws an error
mongoose.connection.on("error", err => {
  console.log("Mongoose default connection error: " + err);
});

// When the connection is disconnected
mongoose.connection.on("disconnected", () => {
  console.log("Mongoose default connection disconnected");
});

// If the Node process ends, close the Mongoose connection
process.on("SIGINT", () => {
  mongoose.connection.close(() => {
    console.log(
      "Mongoose default connection disconnected through app termination"
    );
    process.exit(0);
  });
});

const initAdminDbConnection = DB_URL => {
  try {
    const db = mongoose.createConnection(DB_URL, clientOption);

    db.on(
      "error",
      console.error.bind(
        console,
        "initAdminDbConnection MongoDB Connection Error>> : "
      )
    );
    db.once("open", () => {
      console.log("initAdminDbConnection client MongoDB Connection ok!");
    });

    // require all schemas !?
    require("../dbModel/tenant/schema");
    return db;
  } catch (error) {
    console.log("initAdminDbConnection error", error);
  }
};

module.exports = {
  initAdminDbConnection
};
  • initTenantDbConnection: returns a db connection pool by connecting to the tenant db URI.

 

const mongoose = require("mongoose");
mongoose.Promise = global.Promise;

const clientOption = {
  socketTimeoutMS: 30000,
  keepAlive: true,
  poolSize: 1,
  useNewUrlParser: true,
  useUnifiedTopology: true,
  useFindAndModify: false,
  useCreateIndex: true
};

// CONNECTION EVENTS
// When successfully connected
mongoose.connection.on("connected", () => {
  console.log("Mongoose default connection open");
});

// If the connection throws an error
mongoose.connection.on("error", err => {
  console.log("Mongoose default connection error: " + err);
});

// When the connection is disconnected
mongoose.connection.on("disconnected", () => {
  console.log("Mongoose default connection disconnected");
});

// If the Node process ends, close the Mongoose connection
process.on("SIGINT", () => {
  mongoose.connection.close(() => {
    console.log(
      "Mongoose default connection disconnected through app termination"
    );
    process.exit(0);
  });
});

const initTenantDbConnection = DB_URL => {
  try {
    const db = mongoose.createConnection(DB_URL, clientOption);

    db.on(
      "error",
      console.error.bind(
        console,
        "initTenantDbConnection MongoDB Connection Error>> : "
      )
    );
    db.once("open", () => {
      console.log("initTenantDbConnection client MongoDB Connection ok!");
    });

    // require all schemas !?
    require("../dbModel/user/schema");
    return db;
  } catch (error) {
    console.log("initTenantDbConnection error", error);
  }
};

module.exports = {
  initTenantDbConnection
};
  • connectionManager: This is a helper file that will manage all db connection requests.

 

connectAllDb method needs to be invoked on app boot in index.js. This method will get all the tenants registered in the admin db and then initialise db connections for each tenant. A cache variable connectionMap will hold all the initialised tenant db connections. Whenever a new tenant is created in the admin app, the db connection for that tenant should be added to this connection cache (you can add a method that does this or just simply call connectAllDb again from the service).

Whenever any tenant db request is made, the getConnectionByTenant method will serve a connection to the required tenant db.

Whenever any admin db request is made, the getAdminConnection method will serve the connection to the admin db.

const { getNamespace } = require("continuation-local-storage");

const { BASE_DB_URI, ADMIN_DB_NAME } = require("./config/env.json");
const { initAdminDbConnection } = require("./db/admin");

const { initTenantDbConnection } = require("./db/tenant");

const tenantService = require("./service/tenant");

let connectionMap;
let adminDbConnection;

/**
 * Create knex instance for all the tenants defined in common database and store in a map.
 **/
const connectAllDb = async () => {
  let tenants;
  const ADMIN_DB_URI = `${BASE_DB_URI}/${ADMIN_DB_NAME}`;
  adminDbConnection = initAdminDbConnection(ADMIN_DB_URI);
  console.log("connectAllDb adminDbConnection", adminDbConnection.name);
  try {
    tenants = await tenantService.getAllTenants(adminDbConnection);
    console.log("connectAllDb tenants", tenants);
  } catch (e) {
    console.log("connectAllDb error", e);
    return;
  }

  connectionMap = tenants
    .map(tenant => {
      return {
        [tenant.name]: initTenantDbConnection(tenant.dbURI)
      };
    })
    .reduce((prev, next) => {
      return Object.assign({}, prev, next);
    }, {});
  console.log("connectAllDb connectionMap", connectionMap);
};

/**
 * Get the connection information (knex instance) for the given tenant's slug.
 */
const getConnectionByTenant = tenantName => {
  console.log(`Getting connection for ${tenantName}`);
  if (connectionMap) {
    return connectionMap[tenantName];
  }
};

/**
 * Get the admin db connection.
 */
const getAdminConnection = () => {
  if (adminDbConnection) {
    console.log("Getting adminDbConnection");
    return adminDbConnection;
  }
};

/**
 * Get the connection information (knex instance) for current context. Here we have used a
 * getNamespace from 'continuation-local-storage'. This will let us get / set any
 * information and binds the information to current request context.
 */
const getConnection = () => {
  const nameSpace = getNamespace("unique context");
  const conn = nameSpace.get("connection");

  if (!conn) {
    throw new Error("Connection is not set for any tenant database");
  }

  return conn;
};

module.exports = {
  connectAllDb,
  getAdminConnection,
  getConnection,
  getConnectionByTenant
};
  • connectionResolver: This middleware will take care of resolving db connections for each api request and making the db connection available to be used further down in the api processing chain.

For each tenant api request, resolveTenant middleware method needs to be called which will set the required tenant db connection. We need to pass the tenant identifier in the header which is then used to set the db connection. Each request needs to be scoped so that it is independently handling a request (here I am using continuation-local-storage for holding a connection to the tenant db till the request is processed).

For each admin api request, we set the db connection to the admin db in the same manner using the setAdminDb middleware method.

const { createNamespace } = require("continuation-local-storage");

const {
  getConnectionByTenant,
  getAdminConnection
} = require("../connectionManager");

// Create a namespace for the application.
let nameSpace = createNamespace("unique context");

/**
 * Get the connection instance for the given tenant's name and set it to the current context.
 */
const resolveTenant = (req, res, next) => {
  const tenant = req.headers.tenant;

  if (!tenant) {
    return res
      .status(500)
      .json({ error: `Please provide tenant's name to connect` });
  }

  // Run the application in the defined namespace. It will contextualize every underlying function calls.
  nameSpace.run(() => {
    const tenantDbConnection = getConnectionByTenant(tenant);
    console.log(
      "resolveTenant tenantDbConnection",
      tenantDbConnection && tenantDbConnection.name
    );
    nameSpace.set("connection", tenantDbConnection);
    next();
  });
};

/**
 * Get the admin db connection instance and set it to the current context.
 */
const setAdminDb = (req, res, next) => {
  // Run the application in the defined namespace. It will contextualize every underlying function calls.
  nameSpace.run(() => {
    const adminDbConnection = getAdminConnection();
    console.log("setAdminDb adminDbConnection", adminDbConnection.name);
    nameSpace.set("connection", adminDbConnection);
    next();
  });
};

module.exports = { resolveTenant, setAdminDb };
  • Sample routes along with the middleware for resolving db connections

 

const express = require("express");

// connection resolver for tenant
const connectionResolver = require("../../middlewares/connectionResolver");

// Mounting routes
const v1Routes = express.Router();

v1Routes.use("/tenant", connectionResolver.resolveTenant);
v1Routes.use("/admin", connectionResolver.setAdminDb);

// admin
const adminApi = require("./admin");
v1Routes.post("/admin/tenant", adminApi.create);
v1Routes.get("/admin/tenant", adminApi.fetchAll);

// user
const userApi = require("./user");
v1Routes.post("/tenant/user/signup", userApi.signUp);
v1Routes.get("/tenant/user", userApi.fetchAll);

module.exports = v1Routes;
  • Admin app apis (tenant service): Sample apis for tenant service that will be used for fetching all tenants and creating new tenants. The admin db connection which is provided by the connectionResolver middleware method is passed as a param to the service methods which then call upon the required model and perform db queries on the model.

 

const { getConnection } = require("../../connectionManager");
const tenantService = require("../../service/tenant");

const create = async (req, res) => {
  try {
    const dbConnection = getConnection();
    console.log("create dbConnection", dbConnection.name);
    const tenant = await tenantService.createTenant(dbConnection, req.body);
    res.status(200).json({ success: true, tenant });
  } catch (err) {
    console.log("signUp error", err);
    res.status(err.statusCode || 500).json({ error: err.message });
  }
};

const fetchAll = async (req, res) => {
  try {
    const dbConnection = getConnection();
    console.log("fetchAll dbConnection", dbConnection.name);
    const tenants = await tenantService.getAllTenants(dbConnection);
    res.status(200).json({ success: true, tenants });
  } catch (err) {
    console.log("fetchAll error", err);
    res.status(err.statusCode || 500).json({ error: err.message });
  }
};

module.exports = { create, fetchAll };
  • Tenant app apis (user service): Sample apis for user service that will be used for fetching all users of a tenant and creating new users. The tenant db connection which is provided by the connectionResolver middleware method is passed as a param to the service methods which then call upon the required model and perform db queries on the model.

 

const { getConnection } = require("../../connectionManager");
const userService = require("../../service/user");

const signUp = async (req, res) => {
  try {
    const dbConnection = getConnection();
    console.log("signUp dbConnection", dbConnection.name);
    const user = await userService.createUser(dbConnection, req.body);
    res.status(200).json({ success: true, user });
  } catch (err) {
    console.log("signUp error", err);
    res.status(err.statusCode || 500).json({ error: err.message });
  }
};

const fetchAll = async (req, res) => {
  try {
    const dbConnection = getConnection();
    console.log("fetchAll dbConnection", dbConnection.name);
    const users = await userService.getAllUsers(dbConnection);
    res.status(200).json({ success: true, users });
  } catch (err) {
    console.log("fetchAll error", err);
    res.status(err.statusCode || 500).json({ error: err.message });
  }
};

module.exports = { signUp, fetchAll };

Process flow for api requests

api routes with middleware for resolving db connections

 

This is just a barebones setup which can be modified as per the needs of the system being designed. For eg in our production app I use awilix for DI and managing scopes hence some of things are done a bit differently than as depicted here. I may write another followup article to bring out those specifics in the future and present a boilerplate with DI along with some more tips from our learnings from running the app in production. Till then happy coding 🙂