Multi-Tenant MongoDB Schema Design: Shared Collections vs DB-per-Tenant
Tutorials MongoDB Multi-Tenant SaaS Database Design Node.js Backend Architecture

Multi-Tenant MongoDB Schema Design: Shared Collections vs DB-per-Tenant

D. Rout

D. Rout

September 22, 2026 13 min read

On this page

If you're building a SaaS product on MongoDB, the schema decision you make in week one is the one you'll spend a very unpleasant week two years from now trying to undo. Multi-tenant schema design isn't really a MongoDB-specific problem — it's the same shared-database-vs-isolated-database question every SaaS backend faces — but MongoDB's flexible document model makes it deceptively easy to punt on the decision, ship something that "works," and only discover the actual constraints when a customer signs a contract that requires physical data isolation, or when your free-tier Atlas cluster refuses to create tenant number 101.

This post walks through the three patterns you'll actually choose between — shared collection with a tenant field, database-per-tenant, and collection-per-tenant — with working code for each, a query-level guardrail that turns "an engineer forgot to filter by tenant" into a thrown error instead of a data leak, and a small calculator that tells you exactly how many tenants each pattern supports before you hit MongoDB Atlas's documented cluster limits. I run the shared-collection pattern in production, a multi-tenant loyalty platform for SMBs, so this isn't theoretical — it's the architecture that's currently taking real traffic.

All the code below lives in a working repo you can clone and run: mongo-multi-tenant-patterns.

Prerequisites

  • Node.js 18 or later
  • A MongoDB cluster you can connect to — the free M0 tier on MongoDB Atlas is enough to follow along, and its limits are actually the point of the last section
  • Working familiarity with Mongoose (or the native MongoDB driver — the patterns translate directly)
  • An Express API you're comfortable extending; if you haven't built one before, my Express.js and TypeScript REST API guide covers the project structure this post assumes

Step 1: Decide what "tenant" actually means before you touch a schema

Before writing a single model, nail down your tenant boundary. For most B2B SaaS products it's an organization or account — one tenant might have five human users, and every one of those users' data belongs to the same tenant. Get this wrong (say, treating "user" as the tenant boundary when your product is actually sold per-organization) and you'll be migrating data under a live product later.

For the rest of this post, tenantId is an ObjectId referencing an Organization document. Every schema pattern below assumes that decision is already made and stable.

const { Schema, model } = require('mongoose');

const organizationSchema = new Schema({
  name: { type: String, required: true },
  plan: { type: String, enum: ['free', 'pro', 'enterprise'], default: 'free' },
  createdAt: { type: Date, default: Date.now },
});

module.exports = model('Organization', organizationSchema);

Step 2: Build the shared-collection pattern (the default choice)

Shared collection means every tenant's documents live in the same physical collection, tagged with a tenantId field. This is the pattern to reach for by default — it has the best scaling ceiling of the three and the lowest operational overhead, because you're managing one collection, one set of indexes, and one connection pool regardless of whether you have 5 tenants or 5,000.

The part people get wrong isn't the field — it's the index. A compound index's fields have to be ordered with the most selective, most-frequently-filtered field first, and for a multi-tenant collection that's always tenantId:

const { Schema, model } = require('mongoose');

const orderSchema = new Schema(
  {
    tenantId: { type: Schema.Types.ObjectId, required: true, index: true },
    customerEmail: { type: String, required: true },
    status: {
      type: String,
      enum: ['pending', 'paid', 'fulfilled', 'refunded'],
      default: 'pending',
    },
    totalCents: { type: Number, required: true },
    createdAt: { type: Date, default: Date.now },
  },
  { versionKey: false }
);

// tenantId FIRST. This is the whole trick.
orderSchema.index({ tenantId: 1, status: 1, createdAt: -1 });
orderSchema.index({ tenantId: 1, customerEmail: 1 });

module.exports = model('Order', orderSchema);

Here's the gotcha I actually hit building this for loyalty app: a five-tenant proof of concept will run fine on almost any index order, because MongoDB can brute-force a small collection scan without anyone noticing the latency. The failure mode shows up quietly around tenant #30–50, once the collection has enough documents that a query without tenantId as the index prefix degrades from an index seek into a much larger scan-and-filter. It doesn't error — it just gets slower in a way that's easy to blame on "the database needs bigger hardware" instead of the actual cause, which is index field order chosen when the data was too small to expose it.

Step 3: Add a tenant-scope guardrail so no query goes unscoped

Shared collections trade physical isolation for a rule: every single query has to include tenantId. That rule is only as good as the humans (or AI coding assistants) who remember to follow it every time, so make it a guardrail instead of a convention. A Mongoose query middleware that inspects the filter and throws if tenantId is missing turns a silent cross-tenant data leak into a loud, immediate error in development and CI:

function requireTenantScope() {
  return function (next) {
    const filter = this.getFilter();
    if (!filter.tenantId) {
      return next(
        new Error(
          `Refusing to run ${this.op} on Order without a tenantId filter.`
        )
      );
    }
    next();
  };
}

['find', 'findOne', 'findOneAndUpdate', 'findOneAndDelete', 'countDocuments', 'updateMany', 'deleteMany'].forEach(
  (hook) => orderSchema.pre(hook, requireTenantScope())
);

This is a deliberately opinionated recommendation: I'd rather have this middleware throw in production and cause a 500 than have it silently return another tenant's data because someone wrote Order.find({ status: 'pending' }) without thinking. A thrown error gets noticed and fixed in minutes. A cross-tenant data leak gets noticed in a security audit, or a customer's support ticket, months later.

Step 4: Wrap it in a repository layer

Push the guardrail one level further by making tenantId a required, non-optional first argument on every repository method, not an optional filter someone might forget to pass:

class OrderRepository {
  async listPending(tenantId, { limit = 25 } = {}) {
    return Order.find({ tenantId, status: 'pending' })
      .sort({ createdAt: -1 })
      .limit(limit)
      .lean();
  }

  async create(tenantId, orderData) {
    return Order.create({ ...orderData, tenantId });
  }

  async markFulfilled(tenantId, orderId) {
    return Order.findOneAndUpdate(
      { _id: orderId, tenantId },
      { $set: { status: 'fulfilled' } },
      { new: true }
    );
  }
}

module.exports = new OrderRepository();

If you're wiring this into an Express API, resolve tenantId from a validated JWT claim or API key lookup in middleware — never from a client-supplied header or body field taken at face value. If you want a full walkthrough of that middleware layer with TypeScript, that's exactly what my Express and TypeScript REST API guide covers.

Step 5: Implement database-per-tenant for tenants that need real isolation

Sometimes application-layer isolation isn't enough — an enterprise customer's compliance team wants physical separation, or you have one tenant whose usage pattern is noisy enough to degrade everyone else's p99 latency on a shared collection. Database-per-tenant solves both: each tenant gets its own logical database on the same cluster.

The part nobody warns you about is connection pooling. mongoose.createConnection() opens a real connection pool every time you call it, and a naive implementation that opens a fresh connection per incoming request will exhaust your cluster's connection limit long before it exhausts CPU or storage:

const mongoose = require('mongoose');

class TenantConnectionManager {
  constructor({ baseUri, maxCachedConnections = 50, poolSize = 5 }) {
    this.baseUri = baseUri;
    this.maxCachedConnections = maxCachedConnections;
    this.poolSize = poolSize;
    this.connections = new Map();
  }

  async getConnection(tenantId) {
    const cached = this.connections.get(tenantId);
    if (cached) {
      cached.lastUsed = Date.now();
      return cached.conn;
    }

    if (this.connections.size >= this.maxCachedConnections) {
      this._evictOldest();
    }

    const conn = await mongoose
      .createConnection(`${this.baseUri}/tenant_${tenantId}`, {
        maxPoolSize: this.poolSize,
        minPoolSize: 0,
        serverSelectionTimeoutMS: 5000,
      })
      .asPromise();

    this.connections.set(tenantId, { conn, lastUsed: Date.now() });
    return conn;
  }

  _evictOldest() {
    let oldestTenant = null;
    let oldestTime = Infinity;
    for (const [tenantId, entry] of this.connections) {
      if (entry.lastUsed < oldestTime) {
        oldestTime = entry.lastUsed;
        oldestTenant = tenantId;
      }
    }
    if (oldestTenant) {
      this.connections.get(oldestTenant).conn.close().catch(() => {});
      this.connections.delete(oldestTenant);
    }
  }
}

The math behind why this matters: MongoDB Atlas documents an M10 cluster's connection ceiling at roughly 1,500 connections per node. Fifty concurrent requests per tenant across just 30 tenants, without any pooling or caching, is already 1,500 raw connections — you'd hit the ceiling with a caching layer this thin, on a cluster that otherwise has plenty of CPU and storage headroom left. The LRU cache above keeps a bounded number of pools open and evicts idle tenants, which is the difference between "scales to hundreds of tenants" and "falls over during a traffic spike for reasons that look nothing like a connection problem in your monitoring dashboard."

If you're running the database-per-tenant pattern in production at real scale, you'll eventually want a replica set topology and orchestration that can handle per-tenant database provisioning as part of a deploy pipeline — my Kubernetes guide covers the StatefulSet and headless-service patterns that make running stateful services like this manageable, and for local development, containerizing MongoDB itself is worth doing from day one — see my Docker guide for the base setup.

Step 6: Implement collection-per-tenant — and understand why it's usually the wrong middle ground

Collection-per-tenant looks like a compromise: one database, but each tenant gets a dedicated collection (orders_<tenantId>), avoiding the connection-pool overhead of database-per-tenant while offering "more" isolation than a shared collection.

class CollectionPerTenantOrderRepository {
  constructor(connection) {
    this.connection = connection;
    this._models = new Map();
  }

  _model(tenantId) {
    const collectionName = `orders_${tenantId}`;
    if (this._models.has(collectionName)) return this._models.get(collectionName);

    const schema = new mongoose.Schema(
      {
        customerEmail: { type: String, required: true },
        status: { type: String, enum: ['pending', 'paid', 'fulfilled', 'refunded'], default: 'pending' },
        totalCents: { type: Number, required: true },
        createdAt: { type: Date, default: Date.now },
      },
      { versionKey: false }
    );
    schema.index({ status: 1, createdAt: -1 });

    const model = this.connection.model(collectionName, schema, collectionName);
    this._models.set(collectionName, model);
    return model;
  }

  async listPending(tenantId, { limit = 25 } = {}) {
    return this._model(tenantId).find({ status: 'pending' }).sort({ createdAt: -1 }).limit(limit).lean();
  }
}

Here's the opinionated part: I think this pattern is strictly dominated by the other two, and I'd steer you away from it in almost every case. It doesn't get database-per-tenant's real isolation — it's still one database with one set of role-based access controls, so a compromised or misconfigured connection can still enumerate every tenant's collections. And it inherits the same cluster-wide collection-count ceiling that collections and databases both compete against, which — as the next section shows — is a much lower number than people expect.

Step 7: Put a number on how many tenants your pattern actually supports

This is the part most multi-tenant MongoDB tutorials skip. "Collection-per-tenant doesn't scale" is a true statement that tells you nothing actionable. Here's the actual arithmetic, run against MongoDB's own documented Atlas limits:

$ node scripts/tenant-capacity.js --collectionsPerTenant=4

Pattern: collection-per-tenant (4 collections/tenant)

Tier         Max tenants    Binding constraint
--------------------------------------------------
M0 (free)    125            collection count
M10          1250           collection count
M20/M30      2500           collection count
M40+         25000          collection count
$ node scripts/tenant-capacity.js --collectionsPerTenant=4 --dbPerTenant

Pattern: database-per-tenant (4 collections/tenant)

Tier         Max tenants    Binding constraint
--------------------------------------------------
M0 (free)    100            database count
M10          1250           collection count
M20/M30      2500           collection count
M40+         25000          collection count

The row that should make you stop and reconsider your prototyping plan is M0 database-per-tenant: 100 tenants, flat, regardless of how little data each tenant actually holds. Atlas's free tier caps out at 100 databases per cluster — a limit that has nothing to do with your 512MB storage ceiling and everything to do with cluster metadata overhead. If you're validating a database-per-tenant architecture on a free cluster before committing to a dedicated tier, that's the number that actually matters, not the storage quota everyone reads first.

Reference: schema pattern comparison

Pattern Isolation level Best for M0 tenant ceiling* Operational overhead
Shared collection + tenantId Application-enforced (query filter) Default choice for most SaaS products Not collection/DB-bound — scales with document count and index design Lowest — one collection, one connection pool
Database-per-tenant Physical (separate database) Compliance-driven isolation, noisy-neighbor avoidance 100 (database-count limit, independent of schema size) Highest — per-tenant connection pooling required
Collection-per-tenant Partial (separate collection, shared DB) Rarely the right call — see Step 6 125 at 4 collections/tenant (collection-count limit) Medium, but with the least isolation benefit per unit of overhead

*M0 = MongoDB Atlas free tier; ceilings scale up on dedicated tiers as shown by the capacity calculator. Figures sourced from MongoDB Atlas's documented limits and free/shared tier limitations.

What's next

  • Add tenant-aware rate limiting so one noisy tenant on the shared-collection pattern can't starve API capacity for everyone else — a token-bucket keyed by tenantId at the middleware layer is a natural next step from the guardrail in Step 3.
  • Automate tenant provisioning for the database-per-tenant pattern — a background job that creates the database, applies indexes, and seeds default documents when a new tenant signs up, rather than a manual step.
  • Add a tenant migration path for when a customer outgrows shared-collection isolation and needs to move to database-per-tenant — this is much easier to build before you need it than after a customer is asking for it on a deadline.
  • Instrument per-tenant query performance so you catch the "index prefix wrong" failure mode from Step 2 in staging with realistic data volumes, instead of in production once a real tenant's collection has grown past the point where a bad index order stays invisible.

Further reading

Wrapping up

Multi-tenant schema design in MongoDB isn't a solved problem you copy from a docs page — it's a decision with real tenant-count ceilings attached to it, and those ceilings are usually lower and hit sooner than you'd guess from storage math alone. Start with the shared-collection pattern unless you have a specific, named reason not to; add the query-level guardrail from day one rather than after your first near-miss; and if you do need database-per-tenant, run the capacity calculator against your actual schema before you commit a cluster tier to a customer contract.

All the code in this post — the three repository implementations, the connection manager, and the capacity calculator — is in the companion repo: mongo-multi-tenant-patterns. Clone it, run npm run capacity against your own schema's collection count, and see which tier you actually need before you provision one.

Share

Comments (0)

Join the conversation

Sign in to leave a comment on this post.

No comments yet. to be the first!