Mongoose
Facade-enforced tenant isolation for MongoDB with Mongoose - wired end to end into any framework.
tenancyjs-adapter-mongoose scopes MongoDB access per tenant. This page shows it wired end to end
with Express; using a different framework? Swap only the integration (see
the swap note).
MongoDB has no row-level security. Mongoose isolation is enforced entirely by the adapter's query facade - using the native model, collection, or connection bypasses it completely. Treat it as a strong convention, not a database-level guarantee, and never expose the native handles outside the adapter.
Install
Three pieces: the core, this adapter (your ORM), and an integration (your framework).
npm install tenancyjs-core tenancyjs-adapter-mongoose tenancyjs-integration-express mongooseWire it into your app
Create the manager + adapter
Mongoose needs a connection to a replica set (for managed transactions). Register your tenant-scoped models with the adapter.
import { TenancyManager } from "tenancyjs-core";
import { createMongooseTenancy } from "tenancyjs-adapter-mongoose";
import { createConnection } from "mongoose";
import { OrderModel } from "./models";
export interface Tenant {
readonly id: string;
}
export const manager = new TenancyManager<Tenant>();
const connection = await createConnection(process.env.MONGODB_URL!).asPromise();
export const tenancy = createMongooseTenancy({
manager,
connection,
tenantModels: [{ model: OrderModel }],
});
await tenancy.validate(); // verify the setup before servingBind it to requests
Add the Express middleware. It resolves the tenant per request and opens the scope for its lifetime.
import express from "express";
import { createExpressTenancyMiddleware } from "tenancyjs-integration-express";
import { manager, tenancy } from "./tenancy";
import { OrderModel } from "./models";
const app = express();
app.use(createExpressTenancyMiddleware({ manager, resolver }));
app.get("/orders", async (_req, res) => {
const orders = await tenancy.run((client) =>
client.model(OrderModel).find({ status: "open" }),
);
res.json(orders); // only the current tenant's orders
});That's the whole thing: the adapter injects the tenant filter on reads and the tenant field on writes,
rejects $-operator and dotted keys, and runs everything inside a session-bound transaction. Outside a
tenant scope, it fails closed.
Use a different framework
The adapter half stays identical - only swap the integration import for your framework:
Strategy support
Mongoose supports adapter-enforced row-level isolation and database-per-tenant routing. MongoDB has no SQL schema-per-tenant equivalent.
const tenancy = createMongooseTenancy({
manager,
connection: landlordConnection,
strategy: "databasePerTenant",
tenantModels: [{ model: OrderModel }],
database: (tenant) => ({
key: tenant.databaseKey,
create: () => createTenantConnection(tenant.databaseSecretRef),
}),
});Every tenant connection must register the configured model name and reach a replica set. Shared
credentials provide routing separation; use credentials restricted to one MongoDB database for
server-side authorization. Call close() during shutdown.
Full query freedom: unrestricted()
In row-level mode the facade is the only guard - there is no database backstop (see the warning above)
- so it rejects native handles,
$-operators, and dotted keys; it can't let through what it can't prove is tenant-safe (see Limitations). Database-per-tenant is different: the leased connection is the tenant's own database, so any operation is isolated by construction. There, the client gives you an escape hatch that returns the leasedConnection- aggregation, populate, and the native collection:
const report = await tenancy.run(async (client) => {
// The raw, tenant-scoped Connection - aggregation, populate, native collection.
const connection = client.unrestricted();
return connection
.model(OrderModel.modelName)
.aggregate([
{ $match: { total: { $gt: 1000 } } },
{ $group: { _id: "$customerId", total: { $sum: "$total" } } },
]);
});unrestricted() is fail-closed. It returns the real Connection only in a genuinely
database-enforced scope - a database-per-tenant config running in tenant mode, where a per-tenant
connection was actually leased. Because MongoDB has no row-level backstop, it throws in row-level
scope, and even in a database-per-tenant config used in central mode (which runs on the shared admin
connection, not a tenant's database). The freedom comes from the connection boundary, never from the
config name (ADR-0033).