Skip to content

MongoDB multi-tenancy

@stratum-hq/mongodb adds multi-tenant document isolation for applications using MongoDB or Mongoose. Stratum is the only Node.js multi-tenancy library that supports both PostgreSQL and MongoDB in the same project.

How it fits together: The control plane (tenant hierarchy, config inheritance, audit log, and GDPR operations) always lives in PostgreSQL via @stratum-hq/lib. MongoDB carries your application documents. The two databases are complementary, not competing.

Three isolation strategies are available, set once per deployment:

Strategy Boundary Isolation Level Best For
SHARED_COLLECTION tenant_id field Application-enforced Most applications, high tenant count
COLLECTION_PER_TENANT Collection namespace Application-enforced Apps needing separate indexes per tenant
DATABASE_PER_TENANT Separate MongoDB database Connection-level Compliance requirements, sensitive data

All tenants share the same MongoDB collections. A tenant_id field on every document scopes queries. The Mongoose plugin or adapter automatically injects and filters tenant_id on every operation.

Tradeoff: Isolation is application-enforced. There is no MongoDB equivalent of PostgreSQL Row-Level Security. A bug in your application code could expose cross-tenant data. Mitigate this with assertMongoIsolation() in your test suite and code review discipline.

Each tenant gets its own set of collections, named {collection}_{tenantSlug}. This provides namespace separation but not security isolation: both tenants’ collections still live in the same database and are accessible to the same connection.

List every base collection name in the baseCollections option of MongoCollectionAdapter. scopedCollection() and purgeTenantData() require it. purgeTenantData() purges exactly {base}_{tenantSlug} for each entry, scopedCollection() only accepts those names, and the adapter rejects a list where one name is another name followed by _, because the resulting tenant collection names could collide.

Tradeoff: Good for applications that need different indexes per tenant (e.g., tenant-specific sort orders). Does not prevent a misconfigured query from reading another tenant’s collection. Use assertMongoIsolation() to verify routing is correct.

Each tenant gets a dedicated MongoDB database with its own connection pool. This is the strongest isolation available in MongoDB.

Tradeoff: Each database requires its own connection pool entry. For large tenant counts, set maxClients on MongoDatabaseAdapter and maxPoolSize on each MongoClient to limit the number of MongoDB connections.

Install the package:

npm install @stratum-hq/mongodb @stratum-hq/sdk @stratum-hq/lib pg mongodb mongoose

The Mongoose plugin supports Mongoose 8 and Mongoose 9 (peer range ^8.0.0 || ^9.0.0).

Each strategy has its own adapter class:

Strategy Adapter
SHARED_COLLECTION MongoSharedAdapter
COLLECTION_PER_TENANT MongoCollectionAdapter
DATABASE_PER_TENANT MongoDatabaseAdapter

Basic setup for SHARED_COLLECTION:

import { Pool } from "pg";
import { MongoClient } from "mongodb";
import { Stratum } from "@stratum-hq/lib";
import { MongoSharedAdapter } from "@stratum-hq/mongodb";
// Control plane: always PostgreSQL
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const stratum = new Stratum({ pool, autoMigrate: true });
await stratum.initialize();
// MongoDB adapter
const client = new MongoClient(process.env.MONGODB_URI!);
await client.connect();
const mongo = new MongoSharedAdapter({
client,
databaseName: "app",
});
// Create a tenant (stored in PostgreSQL control plane)
const tenant = await stratum.createTenant({
name: "Acme Corp",
slug: "acme_corp",
});

For COLLECTION_PER_TENANT, list every base collection name:

import { MongoCollectionAdapter } from "@stratum-hq/mongodb";
const mongo = new MongoCollectionAdapter({
client,
databaseName: "app",
baseCollections: ["orders", "invoices"],
});
// The "orders_acme_corp" collection
const orders = mongo.scopedCollection("acme_corp", "orders");

For DATABASE_PER_TENANT, give a factory that opens a client for a connection URI. The adapter replaces the database name in baseUri with stratum_tenant_{tenantSlug}, and opens one client for each tenant:

import { MongoDatabaseAdapter } from "@stratum-hq/mongodb";
const mongo = new MongoDatabaseAdapter({
createClient: async (uri) => {
const tenantClient = new MongoClient(uri, { maxPoolSize: 5 }); // connections per tenant database
await tenantClient.connect();
return tenantClient;
},
baseUri: "mongodb://localhost:27017/app",
maxClients: 100, // close the least recently used client above this count
});
const db = await mongo.getDatabase("acme_corp");
try {
await db.collection("orders").insertOne({ total: 10 });
} finally {
mongo.releaseDatabase("acme_corp");
}

Apply the Stratum plugin to your Mongoose schemas to enable automatic tenant scoping via AsyncLocalStorage (ALS):

import mongoose from "mongoose";
import { stratumPlugin } from "@stratum-hq/mongodb";
const orderSchema = new mongoose.Schema({
product: String,
quantity: Number,
total: Number,
});
// Apply once per schema; injects tenant_id and auto-filters queries
orderSchema.plugin(stratumPlugin);
export const Order = mongoose.model("Order", orderSchema);

The plugin scopes every Mongoose query, insertMany, bulkWrite, aggregate and save to the current tenant, and replaces the model’s watch() with a tenant-scoped change stream. It rejects estimatedDocumentCount, because that count cannot be filtered by tenant. It reads the current tenant from ALS context set by your request middleware.

Wire the ALS context in your web framework middleware. Read the tenant from a verified JWT, not from a request header.

The plugin reads the context from the ALS store of @stratum-hq/sdk. Set the context with runWithTenantContext from that package:

import jwt from "jsonwebtoken";
import { runWithTenantContext } from "@stratum-hq/sdk";
import type { ResolvedTenantContext } from "@stratum-hq/sdk";
// Express example. jwtSecret is the key that verifies the bearer token.
app.use(async (req, res, next) => {
const authorization = req.headers.authorization;
if (!authorization?.startsWith("Bearer ")) {
res.status(401).json({ error: "Missing bearer token" });
return;
}
let claims: string | jwt.JwtPayload;
try {
claims = jwt.verify(authorization.slice("Bearer ".length), jwtSecret, {
algorithms: ["HS256"],
});
} catch {
res.status(401).json({ error: "Invalid bearer token" });
return;
}
const tenantId = typeof claims === "object" ? claims.tenant_id : undefined;
if (typeof tenantId !== "string") {
res.status(401).json({ error: "Token has no tenant_id claim" });
return;
}
// Resolve tenant from your control plane
const tenant = await stratum.getTenant(tenantId);
const context: ResolvedTenantContext = {
tenant_id: tenant.id,
ancestry_path: tenant.ancestry_path,
depth: tenant.depth,
resolved_config: await stratum.resolveConfig(tenant.id),
resolved_permissions: await stratum.resolvePermissions(tenant.id),
isolation_strategy: tenant.isolation_strategy,
};
// Queries on plugin models inside next() see this tenant.
runWithTenantContext(context, next);
});

Once the middleware is in place, all Mongoose queries inside the request are automatically scoped:

// No manual where clause needed; the plugin adds tenant_id automatically
const orders = await Order.find({ status: "pending" });

The plugin filters on the tenant_id field only. It does not select a collection or a database for the tenant. For COLLECTION_PER_TENANT and DATABASE_PER_TENANT, get the collection or the database from the adapter.

The plugin replaces Model.watch() with a change stream that delivers only the current tenant’s events. It matches each event on fullDocument.tenant_id. A delete event has no fullDocument, so by default the stream does not deliver it. Drop, rename and invalidate events are never delivered.

To receive delete events, use the watchDeletes option. The stream then reads the tenant of a delete event from its pre-image. This option needs:

  • MongoDB 6.0 or later, as a replica set or a sharded cluster. Change streams do not run on a standalone server.
  • Change stream pre-images enabled on the collection.

Enable the pre-images on the collection first:

await mongoose.connection.db!.command({
collMod: "orders",
changeStreamPreAndPostImages: { enabled: true },
});

Then pass the option to the plugin:

orderSchema.plugin(stratumPlugin, { watchDeletes: true });
const stream = Order.watch();
stream.on("change", (change) => {
if (change.operationType === "delete") console.log(change.fullDocumentBeforeChange);
});
stream.on("error", (err) => console.error(err));
  • The stream always sets fullDocumentBeforeChange: "required".
  • If the collection has no pre-images, the stream emits an error that names changeStreamPreAndPostImages, and then closes.
  • If a pre-image is not available for an event, the server stops the stream with an error. This occurs for a document written before you enabled pre-images, and for a pre-image that has expired.
  • An update event is not delivered if its pre-image belongs to another tenant.
  • On a discriminator model, Mongoose adds a filter on fullDocument to the stream, so delete events are not delivered.

Each adapter has a purgeTenantData() method that permanently deletes the data of a tenant. Call it after you remove the tenant from the PostgreSQL control plane.

Adapter Argument What it deletes
MongoSharedAdapter tenant ID The documents with that tenant_id in every collection of the database
MongoCollectionAdapter tenant slug All documents in {base}_{tenantSlug} for each entry in baseCollections
MongoDatabaseAdapter tenant slug The database stratum_tenant_{tenantSlug}, which it drops
// MongoSharedAdapter: pass the tenant ID
const result = await mongo.purgeTenantData(tenant.id);

MongoSharedAdapter and MongoCollectionAdapter delete from each collection independently. If one collection fails, the others continue. Check the result for partial failures:

if (!result.success) {
// Log and retry or alert; partial purge means some data remains
console.error("Purge incomplete:", result.errors);
// result.collectionsProcessed counts the collections that succeeded
// result.errors lists { collection, error } for each collection that failed
}

MongoDB does not have a Row-Level Security equivalent. Unlike PostgreSQL, there is no database-level mechanism that enforces tenant isolation independent of application code. This is an important tradeoff to understand for your threat model.

What this means in practice:

  • For SHARED_COLLECTION and COLLECTION_PER_TENANT: if a query is executed without the Mongoose plugin active (e.g., using the raw MongoClient directly), it will see all tenants’ data.
  • Model.collection, Model.db, connection.db and connection.watch() are the raw driver objects, so the plugin does not scope them. Model.watch() is scoped: the plugin filters the change stream to documents whose fullDocument.tenant_id is the current tenant.
  • For DATABASE_PER_TENANT: isolation is at the connection level: a connection opened to tenantA’s database cannot see tenantB’s data. This is the strongest isolation MongoDB offers.

Recommendations:

  1. Use DATABASE_PER_TENANT for sensitive data. This provides connection-level isolation that does not depend on application-layer filtering.
  2. Never bypass the Mongoose plugin. If you need raw MongoClient access, wrap it in the same ALS context and apply the tenant filter manually.
  3. Add assertMongoIsolation() to your test suite (from @stratum-hq/test-utils) to catch regressions before they reach production.
  4. Audit all query paths that touch tenant data, especially background jobs and admin scripts that run outside the normal request middleware.

Without the right indexes, every query in SHARED_COLLECTION mode does a full collection scan. Add a compound index with tenant_id as the leading field:

// Add to your schema before plugin application
orderSchema.index({ tenant_id: 1, created_at: -1 });
orderSchema.index({ tenant_id: 1, status: 1 });

The tenant_id prefix ensures MongoDB uses the index for all tenant-scoped queries. Without it, queries are O(n) across all tenants’ documents.

Each tenant database gets its own MongoClient, and each client has its own connection pool. Tune these values based on your active tenant count and MongoDB instance capacity:

  • maxClients and idleTimeoutMs on MongoDatabaseAdapter.
  • maxPoolSize and minPoolSize on each MongoClient, in your createClient factory.
const mongo = new MongoDatabaseAdapter({
createClient: async (uri) => {
const tenantClient = new MongoClient(uri, {
maxPoolSize: 5, // connections per tenant database
minPoolSize: 1, // keep at least 1 connection warm
});
await tenantClient.connect();
return tenantClient;
},
baseUri: "mongodb://localhost:27017/app",
maxClients: 200, // close the least recently used client above this count (default: 20)
idleTimeoutMs: 30000, // close a client that no caller holds after 30 seconds unused (default: 60000)
});

A rough formula: maxConnections = maxClients * maxPoolSize. For maxClients: 100 at maxPoolSize: 5, budget 500 connections on your MongoDB instance. A client that a caller holds is never closed, so the client count can go above maxClients for a short time.

Collection-per-tenant has minimal additional overhead over shared collection. Each collection gets its own indexes, which is the primary reason to choose this strategy: it allows tenant-specific index configurations without cross-tenant interference.