TenancyJS
Adapters

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 mongoose

Wire 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.

tenancy.ts
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 serving

Bind it to requests

Add the Express middleware. It resolves the tenant per request and opens the scope for its lifetime.

server.ts
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 leased Connection - 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).

On this page