Introduction
Building a multi-tenant SaaS platform demands careful database design choices that balance flexibility, performance, and operational safety. MongoDB for SaaS products for MongoDB & NoSQL developers is not a simple lift-and-shift of single-tenant patterns—it requires intentional architecture around tenant isolation, per-tenant configuration, audit trails, and usage tracking that feed into billing systems.
We've built SaaS platforms across e-commerce, CRM, and travel where MongoDB became the foundation for fast iteration while handling thousands of tenants. The patterns in this article come from production systems managing millions of documents daily, scaling to handle both startup growth and enterprise deployments.
Tenant Isolation: The Non-Negotiable Foundation
Every query in a multi-tenant SaaS system must enforce tenant boundaries. The cost of a tenant data leak—even once—is catastrophic. We enforce isolation in a single middleware layer that wraps all database operations, ensuring no developer can accidentally query across tenants.
Store tenantId on every document. Make it part of your compound indexes. Never query without filtering by tenant:
// ❌ Bad: Missing tenantId filter
db.collection('invoices').findOne({ invoiceId: '123' });
// ✅ Good: Explicit tenant filter
db.collection('invoices').findOne({
tenantId: context.tenantId,
invoiceId: '123'
});Build a query wrapper that injects tenantId automatically. This prevents accidental leaks and makes code audits simpler:
async function safeQuery(collection, filter, options = {}) {
const tenantId = context.tenantId; // From middleware
if (!tenantId) throw new Error('No tenant context');
const enrichedFilter = { ...filter, tenantId };
return db.collection(collection).find(enrichedFilter, options).toArray();
}
// Usage—tenantId injected automatically
const invoices = await safeQuery('invoices', { status: 'unpaid' });Index strategy matters. Create compound indexes that lead with tenantId:
| Collection | Index | Rationale |
|---|---|---|
| invoices | { tenantId: 1, status: 1, createdAt: -1 } | Filter by tenant and status, sort by date |
| events | { tenantId: 1, userId: 1, eventType: 1 } | Tenant-scoped event queries |
| users | { tenantId: 1, email: 1 } | Unique email per tenant (compound uniqueness) |
One production incident we saw: a developer bypassed the query wrapper to debug an issue and ran a raw aggregation pipeline without tenant filtering. The isolation layer caught nothing because it was circumvented. Always code-review any direct db.collection() calls.
Per-Tenant Configuration in Documents
Each tenant has unique settings: branding colors, feature flags, API rate limits, email templates, timezone preferences. Store these in a single tenantConfig document or embedded within a tenants collection.
We typically embed config inside the tenant record itself rather than creating a separate collection—it keeps reads atomic:
db.collection('tenants').insertOne({
_id: ObjectId(),
tenantId: 'acme-corp',
name: 'Acme Corporation',
status: 'active',
config: {
branding: {
primaryColor: '#2563eb',
logoUrl: 'https://cdn.acme.com/logo.png',
customDomain: 'reports.acme.com'
},
features: {
advancedReporting: true,
apiAccess: true,
customIntegrations: false
},
limits: {
maxUsers: 50,
maxApiCallsPerDay: 100000,
dataRetentionDays: 365
},
email: {
senderEmail: 'noreply@acme.com',
senderName: 'Acme Reports',
supportEmail: 'support@acme.com'
},
timezone: 'America/New_York'
},
createdAt: new Date(),
updatedAt: new Date()
});Cache tenant config in memory for 5–15 minutes. Most SaaS features check config on every request, and fetching from MongoDB repeatedly is wasteful:
const configCache = new Map(); // Simple in-process cache
async function getTenantConfig(tenantId) {
const cached = configCache.get(tenantId);
if (cached && Date.now() - cached.fetchedAt < 5 * 60 * 1000) {
return cached.data;
}
const tenant = await db.collection('tenants').findOne({ tenantId });
configCache.set(tenantId, { data: tenant.config, fetchedAt: Date.now() });
return tenant.config;
}
// Invalidate on config changes
async function updateTenantConfig(tenantId, configUpdates) {
await db.collection('tenants').updateOne(
{ tenantId },
{ $set: { config: configUpdates, updatedAt: new Date() } }
);
configCache.delete(tenantId); // Invalidate cache
}For feature flags, consider a separate featureFlags collection if you're running A/B tests or rolling out features gradually:
db.collection('featureFlags').insertOne({
_id: ObjectId(),
tenantId: 'acme-corp',
flagName: 'newDashboard',
enabled: true,
rolloutPercentage: 25, // Gradual rollout
rules: {
excludeTenantIds: [], // Opt-out list
includeTenantIds: [] // Whitelist override
}
});Audit Logs: Append-Only Collections with TTL or Archive
Compliance and debugging demand complete audit trails. Store immutable logs in an append-only collection. Never update or delete audit records—only insert.
db.collection('auditLogs').insertOne({
_id: ObjectId(),
tenantId: 'acme-corp',
userId: 'user-456',
action: 'INVOICE_CREATED',
resourceType: 'invoice',
resourceId: 'inv-789',
changes: {
before: null,
after: {
amount: 5000,
status: 'draft',
dueDate: new Date('2025-02-15')
}
},
ipAddress: '203.0.113.42',
userAgent: 'Mozilla/5.0...',
timestamp: new Date(),
metadata: {
endpoint: 'POST /invoices',
responseTime: 245 // ms
}
});TTL (Time-To-Live) indexes automatically remove old logs to control storage costs. For compliance, archive logs older than the retention period to cold storage (S3, GCS) before deletion:
// Create TTL index: expire after 2 years (in seconds)
db.collection('auditLogs').createIndex(
{ timestamp: 1 },
{ expireAfterSeconds: 63072000 }
);
// Before TTL kicks in, archive to S3
async function archiveOldLogs() {
const cutoff = new Date();
cutoff.setFullYear(cutoff.getFullYear() - 2);
const logs = await db.collection('auditLogs')
.find({ timestamp: { $lt: cutoff } })
.limit(10000)
.toArray();
if (logs.length > 0) {
await uploadToS3(`audit-logs-${Date.now()}.jsonl`, logs);
await db.collection('auditLogs').deleteMany({ timestamp: { $lt: cutoff } });
}
}Usage Metrics: Time Series Collections for Billing Data
Track usage metrics—API calls, active users, data processed, storage used—with MongoDB's time series collections. They're optimized for high-cardinality metric data and built-in downsampling.
// Create time series collection
db.createCollection('usageMetrics', {
timeseries: {
timeField: 'timestamp',
metaField: 'metadata',
granularity: 'hours' // For hourly metrics
}
});
// Insert usage events
db.collection('usageMetrics').insertOne({
timestamp: new Date(),
metadata: {
tenantId: 'acme-corp',
metricType: 'apiCalls'
},
value: 1250
});
// Insert multiple metrics in one operation
db.collection('usageMetrics').insertMany([
{
timestamp: new Date(),
metadata: { tenantId: 'acme-corp', metricType: 'activeUsers' },
value: 42
},
{
timestamp: new Date(),
metadata: { tenantId: 'acme-corp', metricType: 'storageGB' },
value: 12.5
},
{
timestamp: new Date(),
metadata: { tenantId: 'acme-corp', metricType: 'dataProcessed' },
value: 2500
}
]);Query metrics for billing reconciliation efficiently:
// Calculate total API calls for tenant in billing period
async function getBillingMetrics(tenantId, startDate, endDate) {
return db.collection('usageMetrics').aggregate([
{
$match: {
'metadata.tenantId': tenantId,
'metadata.metricType': 'apiCalls',
timestamp: { $gte: startDate, $lte: endDate }
}
},
{
$group: {
_id: null,
totalApiCalls: { $sum: '$value' },
avgPerHour: { $avg: '$value' },
peakValue: { $max: '$value' }
}
}
]).toArray();
}
// Usage for generating monthly invoices
const metrics = await getBillingMetrics('acme-corp', billingStart, billingEnd);
const tieredPrice = calculateTieredPrice(metrics.totalApiCalls);Time series collections automatically compress data. After 30 days (configurable), MongoDB compacts measurements into buckets, reducing storage by up to 90% compared to regular collections.
Common Mistakes
- Querying without tenant context at startup. If you initialize caches or background jobs before tenant context is set, you'll get cross-tenant data. Always inject tenant context explicitly; never assume a global default.
- Storing plain-text secrets in config documents. API keys, database passwords, OAuth tokens don't belong in MongoDB. Use a secrets manager (AWS Secrets Manager, HashiCorp Vault) and store only a reference or encrypted value.
- Audit logs without indexing for compliance queries. Create indexes on
{ tenantId: 1, action: 1, timestamp: -1 }and{ tenantId: 1, userId: 1, timestamp: -1 }to make compliance audits fast. - Time series collections with wrong granularity. If you're tracking per-minute usage but set
granularity: 'hours', your queries will be slower. Match granularity to your data frequency. - No cache invalidation strategy for config. Pushing a config update to production but not clearing the in-process cache leads to stale feature flags and settings. Implement cache invalidation in your update flow or use short TTLs (5–10 minutes).
Key Takeaways
- Enforce tenant isolation in a single middleware layer; inject
tenantIdon every query and index with it as the leading field. - Embed tenant config in the tenant document and cache it in-process with 5–15 minute TTLs to reduce database load.
- Build audit logs as append-only collections; use TTL indexes for retention and archive to cold storage before deletion for compliance.
- Use MongoDB's time series collections for billing metrics; they compress automatically and enable efficient aggregation for invoice generation.
- Review all database access patterns in code review; any bypassing of the tenant isolation wrapper is a security risk.






Comments
Loading comments…