Multi-Tenancy
Multi-tenant architecture patterns in Node.js — subdomain-based tenant detection, per-tenant database connections, schema-per-tenant with Knex, and request-scoped tenant context using AsyncLocalStorage.
Subdomain Detection Middleware
Identify the tenant from the request subdomain (e.g. acme.myapp.com) and attach it to req.tenant for downstream handlers.
// middleware/tenantResolver.js
const AppError = require('../lib/AppError');
const Tenant = require('../models/Tenant');
/**
* Resolves tenant from subdomain and attaches to req.tenant.
* Expects requests like: acme.myapp.com, beta.myapp.com
*/
async function tenantResolver(req, res, next) {
const host = req.hostname; // e.g. acme.myapp.com
const parts = host.split('.');
const subdomain = parts.length >= 3 ? parts[0] : null;
// Allow requests to the root domain (landing page, auth, etc.)
if (!subdomain || subdomain === 'www') return next();
const tenant = await Tenant.findBySlug(subdomain);
if (!tenant) {
return next(AppError.notFound(`Tenant "${subdomain}" not found`));
}
if (!tenant.isActive) {
return next(new AppError('Tenant account is suspended', 403, 'TENANT_SUSPENDED'));
}
req.tenant = tenant; // { id, slug, name, dbName, plan, ... }
next();
}
module.exports = tenantResolver;
// ── Register globally (after body-parser, before routes) ──────
// app.use(tenantResolver);Per-Tenant Database Connection Switching
Maintain a pool of Knex connections keyed by tenant ID. Create connections lazily and reuse them across requests to avoid connection storms.
npm install knex mysql2
// lib/tenantDb.js
const knex = require('knex');
// Map of tenantId -> knex instance
const connections = new Map();
/**
* Returns (or creates) a Knex connection for a given tenant.
* @param {object} tenant - { id, dbName }
*/
function getTenantDb(tenant) {
if (connections.has(tenant.id)) {
return connections.get(tenant.id);
}
const db = knex({
client: 'mysql2',
connection: {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: tenant.dbName, // e.g. "tenant_acme"
},
pool: { min: 1, max: 5 },
});
connections.set(tenant.id, db);
return db;
}
/**
* Destroy a tenant connection (e.g. on tenant deactivation).
*/
async function destroyTenantDb(tenantId) {
const db = connections.get(tenantId);
if (db) {
await db.destroy();
connections.delete(tenantId);
}
}
module.exports = { getTenantDb, destroyTenantDb };Schema-Per-Tenant with Knex Migrations
Run Knex migrations against a specific tenant's database to provision new tenants or apply schema updates. Useful when each tenant gets an isolated MySQL database.
// scripts/provision-tenant.js
const knex = require('knex');
const Tenant = require('../models/Tenant');
async function provisionTenant(tenantSlug) {
// 1. Create tenant record in master DB
const tenant = await Tenant.create({ slug: tenantSlug, dbName: `tenant_${tenantSlug}` });
// 2. Create the tenant database
const root = knex({ client: 'mysql2', connection: {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
}});
await root.raw(`CREATE DATABASE IF NOT EXISTS \`tenant_${tenantSlug}\``);
await root.destroy();
// 3. Run migrations on the new tenant DB
const tenantDb = knex({ client: 'mysql2', connection: {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: `tenant_${tenantSlug}`,
}});
await tenantDb.migrate.latest({ directory: './src/migrations/tenant' });
await tenantDb.seed.run({ directory: './src/seeds/tenant' });
await tenantDb.destroy();
console.log(`✅ Tenant "${tenantSlug}" provisioned successfully`);
return tenant;
}
module.exports = { provisionTenant };Tenant Context with AsyncLocalStorage
AsyncLocalStorage (Node.js built-in since v12) provides request-scoped storage without passing req everywhere — like a thread-local for async contexts.
// lib/tenantContext.js
const { AsyncLocalStorage } = require('async_hooks');
const tenantStorage = new AsyncLocalStorage();
/**
* Run a function within a tenant context.
* All async code spawned inside fn() can read the tenant.
*/
function runWithTenant(tenant, fn) {
return tenantStorage.run({ tenant }, fn);
}
function getTenant() {
const store = tenantStorage.getStore();
if (!store?.tenant) throw new Error('No tenant context – are you inside a request?');
return store.tenant;
}
function getTenantSafe() {
return tenantStorage.getStore()?.tenant ?? null;
}
module.exports = { runWithTenant, getTenant, getTenantSafe };Wiring AsyncLocalStorage into Express
Set up the tenant context in middleware so every downstream service function (including deeply nested calls) can call getTenant() without needing req as a parameter.
// middleware/tenantContext.js
const { runWithTenant } = require('../lib/tenantContext');
function tenantContextMiddleware(req, res, next) {
if (!req.tenant) return next();
// Wrap rest of request pipeline in tenant context
runWithTenant(req.tenant, () => next());
}
module.exports = tenantContextMiddleware;
// ── Usage in any service (no req needed!) ─────────────────────
// services/productService.js
const { getTenant } = require('../lib/tenantContext');
const { getTenantDb } = require('../lib/tenantDb');
async function listProducts() {
const tenant = getTenant(); // reads from AsyncLocalStorage
const db = getTenantDb(tenant);
return db('products').select('*').orderBy('name');
}
// ── app.js registration order ─────────────────────────────────
// app.use(tenantResolver); // 1. detect tenant from subdomain
// app.use(tenantContextMiddleware); // 2. set AsyncLocalStorage
// app.use('/api', apiRouter); // 3. routes can call getTenant() anywhere