@phen0menon/nestjs-mongoose-tenancy
v1.0.1
Published
Mongoose tenancy for NestJS
Readme
- ⚡ Zero runtime overhead — models are resolved at the DI level
- 🚀 Blazing fast tenant switching with request-scoped providers
- 🔌 Eager startup — all connections are created and validated upfront
- 🛡️
commontenant built-in as a reliable fallback - ✨ Clean DX —
@InjectTenantModel()feels just like regular NestJS - 🧵 First-class background support —
@InjectTenantModelMap()for workers, queues and jobs - 🛠️ Predictable behavior — clear rules and excellent error messages
- 📘 Full TypeScript support — excellent types, strict mode and full IDE integration
Install
# bun
bun add @phen0menon/nestjs-mongoose-tenancy @nestjs/mongoose mongoose
# npm
npm install @phen0menon/nestjs-mongoose-tenancy @nestjs/mongoose mongoose
# yarn
yarn add @phen0menon/nestjs-mongoose-tenancy @nestjs/mongoose mongooseQuick Start
1. Register Connections And Schemas
import { Module } from '@nestjs/common'
import { MongooseTenancyModule, fromHeader } from '@phen0menon/nestjs-mongoose-tenancy'
import { Product, ProductSchema } from './product.schema'
import { ProductService } from './product.service'
@Module({
imports: [
MongooseTenancyModule.forRoot({
common: {
uri: process.env.MONGO_COMMON_URI!,
mongooseOptions: { dbName: 'common' },
},
tenants: [
{
id: 'org-1',
uri: process.env.MONGO_ORG_1_URI!,
mongooseOptions: { dbName: 'org_1' },
},
],
tenantResolver: fromHeader('x-tenant-id'),
}),
MongooseTenancyModule.forFeature([{ name: Product.name, schema: ProductSchema }]),
],
providers: [ProductService],
})
export class AppModule {}2. Define A Normal Mongoose Schema
Use regular @nestjs/mongoose schemas. Any ModelDefinition accepted by MongooseModule.forFeature() works here too.
3. Inject The Tenant Model
import { Injectable } from '@nestjs/common'
import { InjectTenantModel } from '@phen0menon/nestjs-mongoose-tenancy'
import { Model } from 'mongoose'
import { Product, ProductDocument } from './product.schema'
@Injectable()
export class ProductService {
constructor(
@InjectTenantModel(Product.name)
private readonly products: Model<ProductDocument>,
) {}
async list(): Promise<Product[]> {
return this.products.find().lean()
}
}With this setup, x-tenant-id: org-1 uses the org-1 connection. Missing or unknown tenant ids use common.
Tenant Resolution
tenantResolver returns a tenant id for each request. If you omit it, every request uses common.
import { COMMON_TENANT_ID, composeResolvers, fromHeader, fromRequest } from '@phen0menon/nestjs-mongoose-tenancy'
tenantResolver: fromHeader('x-tenant-id')
tenantResolver: fromRequest<{ clientMeta?: { tenant?: string } }>((request) => request.clientMeta?.tenant)
tenantResolver: composeResolvers(fromHeader('x-tenant-id'), () => COMMON_TENANT_ID)Resolution rules:
- Missing tenant ->
COMMON_TENANT_ID('common') - Unknown tenant ->
COMMON_TENANT_ID - Known connected tenant -> that tenant
- Known disconnected tenant ->
TenantConnectionUnavailableException - Business lookup stays in your app: middleware, guard, or your own resolver
Connection Configuration
common.uri is required. tenants can be omitted, a static array, or a startup factory.
mongooseOptions are passed to mongoose.createConnection().
- Global options apply to
commonand every tenant. - Local options on
commonor a tenant override global options with a shallow merge. - Tenant ids must be unique and cannot be
common.
Async Configuration
Use forRootAsync() when config comes from Nest providers.
MongooseTenancyModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
common: { uri: config.getOrThrow('MONGO_COMMON_URI') },
tenants: [{ id: 'org-1', uri: config.getOrThrow('MONGO_ORG_1_URI') }],
tenantResolver: fromHeader('x-tenant-id'),
}),
})Startup Tenant Discovery
tenants can be an async factory. It runs once after common connects.
MongooseTenancyModule.forRoot({
common: { uri: process.env.MONGO_COMMON_URI! },
tenants: async ({ commonConnection }) => {
const rows = await commonConnection.collection('tenants').find({ enabled: true }).toArray()
return rows.map((row) => ({ id: row.id, uri: row.mongoUri }))
},
tenantResolver: fromHeader('x-tenant-id'),
})Important: the returned tenants are frozen after startup.
Startup Failure Behavior
Default behavior is fail-fast: if common or any configured tenant cannot connect, the app does not start.
To start with disconnected tenants, use degraded startup:
MongooseTenancyModule.forRoot({
common: { uri: process.env.MONGO_COMMON_URI! },
tenants: [{ id: 'org-1', uri: process.env.MONGO_ORG_1_URI! }],
tenantResolver: fromHeader('x-tenant-id'),
startup: {
onTenantConnectionError: 'continue',
retryAttempts: 5,
retryDelayMs: 1000,
},
})Known disconnected tenants throw TenantConnectionUnavailableException until reconnected.
Non-HTTP Flows
Queues, schedulers, CLI commands, and other non-HTTP flows do not have a request context. Inject the readonly model map and choose the tenant yourself.
import { Injectable } from '@nestjs/common'
import { COMMON_TENANT_ID, InjectTenantModelMap } from '@phen0menon/nestjs-mongoose-tenancy'
import { Model } from 'mongoose'
import { Product, ProductDocument } from './product.schema'
@Injectable()
export class ProductJobHandler {
constructor(
@InjectTenantModelMap(Product.name)
private readonly productModels: ReadonlyMap<string, Model<ProductDocument>>,
) {}
async handle(message: { tenant?: string }): Promise<number> {
const model =
(message.tenant ? this.productModels.get(message.tenant) : undefined) ??
this.productModels.get(COMMON_TENANT_ID)
if (!model) {
throw new Error('Product model is not registered')
}
return model.countDocuments()
}
}Examples
Runnable examples:
examples/request-scoped: request-scoped model injectionexamples/header-resolver:fromHeader()examples/startup-discovery: load tenants fromcommonexamples/runtime-worker:@InjectTenantModelMap()examples/degraded-startup: start with a disconnected tenantexamples/realword-example: real Mongoose connections withmongodb-memory-server
