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.
Choosing a strategy
Section titled “Choosing a strategy”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 |
Shared collection
Section titled “Shared collection”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.
Collection-per-Tenant
Section titled “Collection-per-Tenant”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.
Database-per-Tenant
Section titled “Database-per-Tenant”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.
Getting started
Section titled “Getting started”Install the package:
npm install @stratum-hq/mongodb @stratum-hq/sdk @stratum-hq/lib pg mongodb mongooseThe 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 PostgreSQLconst pool = new Pool({ connectionString: process.env.DATABASE_URL });const stratum = new Stratum({ pool, autoMigrate: true });await stratum.initialize();
// MongoDB adapterconst 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" collectionconst 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");}Mongoose plugin
Section titled “Mongoose plugin”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 queriesorderSchema.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.
ALS context setup
Section titled “ALS context setup”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 automaticallyconst 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.
Change streams and delete events
Section titled “Change streams and delete events”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
errorthat nameschangeStreamPreAndPostImages, 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
fullDocumentto the stream, so delete events are not delivered.
GDPR compliance
Section titled “GDPR compliance”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 IDconst result = await mongo.purgeTenantData(tenant.id);Partial failure handling
Section titled “Partial failure handling”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}Security considerations
Section titled “Security considerations”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_COLLECTIONandCOLLECTION_PER_TENANT: if a query is executed without the Mongoose plugin active (e.g., using the rawMongoClientdirectly), it will see all tenants’ data. Model.collection,Model.db,connection.dbandconnection.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 whosefullDocument.tenant_idis 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:
- Use
DATABASE_PER_TENANTfor sensitive data. This provides connection-level isolation that does not depend on application-layer filtering. - Never bypass the Mongoose plugin. If you need raw
MongoClientaccess, wrap it in the same ALS context and apply the tenant filter manually. - Add
assertMongoIsolation()to your test suite (from@stratum-hq/test-utils) to catch regressions before they reach production. - Audit all query paths that touch tenant data, especially background jobs and admin scripts that run outside the normal request middleware.
Performance
Section titled “Performance”Shared collection index requirements
Section titled “Shared collection index requirements”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 applicationorderSchema.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.
Database-per-Tenant pool tuning
Section titled “Database-per-Tenant pool tuning”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:
maxClientsandidleTimeoutMsonMongoDatabaseAdapter.maxPoolSizeandminPoolSizeon eachMongoClient, in yourcreateClientfactory.
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
Section titled “Collection-per-Tenant”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.
See also
Section titled “See also”- Compare Stratum with plain MongoDB, feature by feature