The question every multi-tenant app has to answer
Once a SaaS product has more than one paying customer sharing the same application, there's a single design decision that shapes almost everything downstream: how do you physically separate one tenant's data from another's? Get it right early and it barely comes up again. Get it wrong, and it becomes the kind of bug that ends up in a breach disclosure instead of a bug tracker.
There are three approaches most teams reach for, and each one trades isolation strength for operational simplicity in a different place.
Approach 1 - shared tables with a tenant_id column
This is the default most teams start with, and for good reason: one schema, one connection pool, one set of migrations. Every tenant-owned table gets a tenantId column, and every single query filters on it.
// Every query MUST include tenantId - there is no schema-level
// guarantee that protects you if a developer forgets it.
const invoices = await Invoice.find({
tenantId: currentTenant._id,
status: "paid",
});
The risk here isn't theoretical - it's that isolation now depends entirely on every developer remembering to add that filter, every single time, in every query, forever. Miss it once in an admin endpoint and you've built a cross-tenant data leak. The fix most production systems land on is to never let a raw query run without it, by wrapping the model layer so the filter is impossible to skip.
// A thin repository wrapper that injects tenantId automatically,
// so individual call sites can't accidentally omit it.
class TenantScopedRepo {
constructor(model, tenantId) {
this.model = model;
this.tenantId = tenantId;
}
find(filter = {}) {
return this.model.find({ ...filter, tenantId: this.tenantId });
}
findOne(filter = {}) {
return this.model.findOne({ ...filter, tenantId: this.tenantId });
}
}
MongoDB makes this pattern especially natural because you can also compound-index on { tenantId: 1, ...otherFields } so every tenant-scoped query stays fast even as the collection grows into the millions of documents shared across tenants.
Approach 2 - one schema per tenant, same database
PostgreSQL's schema feature lets you keep one physical database but give each tenant its own namespace - tenant_acme.invoices versus tenant_globex.invoices. Isolation is enforced by the database engine itself rather than by application code remembering to filter correctly, which removes an entire category of "someone forgot the WHERE clause" bugs.
-- Switching the search_path effectively scopes every unqualified -- query to that tenant's schema for the rest of the session. SET search_path TO tenant_acme, public; SELECT * FROM invoices WHERE status = 'paid';
The catch is migrations. Adding a column now means running that migration against every tenant schema, not once. At a few dozen tenants this is a script you run in a loop. At a few thousand, it becomes its own piece of infrastructure - usually a queued migration runner that works through schemas in batches and can resume if one fails partway through.
Approach 3 - a database per tenant
This is the strongest isolation available short of physically separate infrastructure, and it's what regulated industries (healthcare, finance) usually end up requiring for their larger customers. Each tenant gets a dedicated database, sometimes on dedicated hardware. A connection router looks up which database a request belongs to before anything else happens.
async function getTenantConnection(tenantId) {
const cached = connectionPool.get(tenantId);
if (cached) return cached;
const tenant = await MasterDB.Tenant.findById(tenantId).lean();
const conn = mongoose.createConnection(tenant.dbUri, {
maxPoolSize: 5,
});
connectionPool.set(tenantId, conn);
return conn;
}
The obvious cost is operational: backups, monitoring, connection pooling, and schema migrations all now happen N times instead of once. Most teams that go this route reserve it for enterprise-tier customers specifically, while smaller customers stay on a shared database - a hybrid model rather than an all-or-nothing choice.
Picking one without over-building
Shared-table with enforced scoping is the right starting point for almost every SaaS - it's cheap to build, cheap to run, and the wrapper pattern above closes the main risk. Move to schema-per-tenant when a specific customer contractually requires stronger isolation, and reserve database-per-tenant for the handful of enterprise accounts where the isolation guarantee is actually part of what you're selling. Building database-per-tenant on day one for a product with three customers is optimizing for a scale problem you don't have yet, at the cost of a migration story you'll be maintaining forever.
Comments
Loading comments...
Related Articles
Taming Slow MongoDB Aggregation Pipelines in Production
An aggregation pipeline that returns instantly on your 500-document local dataset can crawl once it hits a few million real documents. Here's how to actually find and fix the stage that's costing you.
Read ArticleBuilding a Sliding-Window Rate Limiter for Node.js APIs with Redis
Fixed-window rate limiting looks fine in a demo and then lets through double the traffic right at the window boundary. Here's the sliding-window-counter approach that fixes that, built directly on Redis.
Read ArticleGetting Started with NestJS if You Already Know Express
NestJS looks like a lot of ceremony at first glance. Once you see how modules, controllers, and providers map onto things you already do in Express, it clicks fast.
Read Article