Multi-tenant
Give each tenant its own database, with db as a function that returns the database of the current request.
When each tenant has its own database, db takes a function instead of a Db. The model calls it
on every operation, so one model serves every tenant: the function returns the database of the
request that is running, here from an
AsyncLocalStorage.
import type { Db } from 'mongodb'
import { AsyncLocalStorage } from 'node:async_hooks'
import { ObjectId } from 'mongodb'
import { mongozod } from 'mongozod'
import * as mz from 'mongozod/mzod'
import * as z from 'zod'
const tenantDb = new AsyncLocalStorage<Db>()
function currentDb() {
const found = tenantDb.getStore()
if (!found) throw new Error('no tenant: run the operation inside withTenant()')
return found
}
const postSchema = z.object({
_id: mz.objectId()
.default(() => new ObjectId()),
title: z.string(),
status: z.enum(['draft', 'published'])
.default('draft')
})
export const PostsModel = mongozod({
collection: 'posts',
db: currentDb,
schema: postSchema
})Keep a Db per tenant
Each request runs inside tenantDb.run(), with the database of its tenant. Keep one Db per
tenant: client.db(name) builds a new one on each call, and the model keeps its collection per
Db, so returning the same one reuses it. Every Db shares the connections of the client.
const tenantDbs = new Map<string, Db>()
/** Runs a function, such as the handler of a request, with the database of a tenant. */
export function withTenant<T>(tenant: string, run: () => T): T {
let found = tenantDbs.get(tenant)
if (!found) {
found = client.db(`blog_${tenant}`)
tenantDbs.set(tenant, found)
}
return tenantDb.run(found, run)
}
// In the middleware that authenticates each request, with the tenant of its user.
await withTenant('acme', () => PostsModel.insertOne({ title: 'Hello, MongoDB' }))Every operation runs on the tenant's database, aggregate and populate included.
PostsModel.db and PostsModel.collection give the database and the collection of the function's
Db at that moment, in your code and in the hooks of the plugins. A function that throws, like
currentDb outside of a request, rejects the operation.
Cursors keep their tenant
A cursor of find or aggregate starts on its first read, but it runs in the async context where
it was created, like a promise. One created in a request and read later still reads its tenant's
database, also for populate and the hooks of the plugins:
const cursor = withTenant('acme', () => PostsModel.find({ status: 'published' }))
// Outside of the request: still the posts of acme.
const posts = await cursorThe other way around, a cursor created outside of a request, such as in a helper that builds it
before entering withTenant(), doesn't see the tenant, even when it is read inside one. With
currentDb, its first read throws.
Sync each tenant
sync() creates the indexes and the $jsonSchema in the database the function returns, so run it
in each tenant, such as when one is created or on each deploy:
for (const tenant of ['acme', 'globex']) {
await withTenant(tenant, () => PostsModel.sync())
}Why a database per tenant
A database per tenant isolates every operation, aggregate and .collection included. A shared
collection with a tenantId field relies on a plugin that fills and filters
it, but aggregate takes no context and skips the group hooks of the plugins, so each pipeline
has to filter the tenant in its own $match.
Mongoose resolves the same case with useDb(), cached with useCache: true, and a model per
tenant (Mongoose docs). A
model per tenant also works with MongoZod, but one model with a function is enough.
Common mistakes
db: () => client.db(name): builds a newDbon each operation, so the model never reuses its collection. Keep aDbper tenant, aswithTenant()does.- A cursor created before the request: it reads the database of where it was created, not of
where it is read. Create it inside
withTenant(). sync()once at startup: only the database the function returns then gets the indexes. Run it in each tenant.- A fallback to a default database:
tenantDb.getStore() ?? client.db()sends an operation that runs outside of a request to the default database without an error. Throw instead, unless that database is meant to be shared.
Last updated on
MongoZod is an independent open-source project. It is not affiliated with, endorsed by or sponsored by MongoDB, Inc. MongoDB® is a registered trademark of MongoDB, Inc.