Multi-Tenancy
When building SaaS services, you may need to completely isolate data per customer (tenant). Data from Company A's user list should never be mixed with Company B's data.
Stingerloom ORM supports multi-tenancy through a layered metadata system. It works on the same principle as Docker's OverlayFS.
How It Works
┌──────────────────────────────────┐
│ Tenant Layer (read/write) │ <- Per-tenant modifications
│ e.g. overrides for "acme_corp" │
├──────────────────────────────────┤
│ Public Layer (read-only) │ <- Base schema (all entities)
│ @Entity, @Column base defs │
└──────────────────────────────────┘Read: Checks the tenant layer first, and falls back to the Public layer if not found.
Write: Records only to the current tenant layer (Copy-on-Write). The Public layer is never modified.
The core concept is simple: set "which tenant this request belongs to," and the ORM handles the rest.
Basic Usage
Use MetadataContext.run() to execute code within a specific tenant context.
import { MetadataContext } from "@stingerloom/orm";
// Execute within tenant_1 context
await MetadataContext.run("tenant_1", async () => {
const users = await em.find(User);
// -> Looks up tenant_1 layer -> public layer in order
});
// Outside the callback, automatically reverts to "public"MetadataContext.run() uses AsyncLocalStorage, so the same tenant context is maintained across all async functions called within the callback.
// Check current tenant
const tenant = MetadataContext.getCurrentTenant(); // "tenant_1" or "public"
const isActive = MetadataContext.isActive(); // true / falseAutomatic Setup with NestJS Middleware
In real services, you set the tenant automatically per HTTP request. Using middleware, tenant isolation is applied to all controllers and services without additional code.
// tenant.middleware.ts
import { Injectable, NestMiddleware } from "@nestjs/common";
import { Request, Response, NextFunction } from "express";
import { MetadataContext } from "@stingerloom/orm";
@Injectable()
export class TenantMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const tenantId = req.headers["x-tenant-id"] as string ?? "public";
// Wrap the entire request in a tenant context
MetadataContext.run(tenantId, () => {
next();
});
}
}// app.module.ts
@Module({ /* ... */ })
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(TenantMiddleware).forRoutes("*");
}
}Now just add the X-Tenant-Id header when calling the API.
# Query users for acme_corp tenant
curl -H "X-Tenant-Id: acme_corp" http://localhost:3000/users
# Query posts for globex tenant
curl -H "X-Tenant-Id: globex" http://localhost:3000/posts
# Without the header -> "public" context
curl http://localhost:3000/usersPostgreSQL Schema-Based Isolation
In PostgreSQL, you can use schemas to physically and completely isolate tenant data. TenantMigrationRunner automatically creates per-tenant schemas.
Tenant Schema Provisioning
import { PostgresTenantMigrationRunner, EntityManager } from "@stingerloom/orm";
const em = new EntityManager();
await em.register({
type: "postgres",
host: "localhost",
port: 5432,
username: "postgres",
password: "password",
database: "mydb",
entities: [User, Post],
synchronize: true,
});
const driver = em.getDriver()!;
const runner = new PostgresTenantMigrationRunner(driver, {
sourceSchema: "public", // Replicate table structure from this schema
});Creating a Single Tenant
await runner.ensureSchema("acme_corp");
// -> CREATE SCHEMA "acme_corp"
// -> Replicate each table with CREATE TABLE ... (LIKE "public"."users" INCLUDING ALL)Batch Creating Multiple Tenants
const result = await runner.syncTenantSchemas([
"acme_corp", "globex", "initech", "umbrella"
]);
console.log(result.created); // ["initech", "umbrella"] — Newly created
console.log(result.skipped); // ["acme_corp", "globex"] — Already existDiscovering Existing Schemas
const schemas = await runner.discoverSchemas();
// ["public", "acme_corp", "globex"]
runner.isProvisioned("acme_corp"); // true
runner.getProvisionedSchemas(); // ["acme_corp", "globex", ...]Auto-Provisioning in NestJS
You can create a service that automatically creates all tenant schemas when the app starts.
// tenant-provisioning.service.ts
import { Injectable, OnModuleInit } from "@nestjs/common";
import { EntityManager, PostgresTenantMigrationRunner } from "@stingerloom/orm";
@Injectable()
export class TenantProvisioningService implements OnModuleInit {
private runner: PostgresTenantMigrationRunner;
constructor(private readonly em: EntityManager) {}
async onModuleInit() {
const driver = this.em.getDriver()!;
this.runner = new PostgresTenantMigrationRunner(driver);
// Provision tenants on app startup
await this.runner.syncTenantSchemas([
"acme_corp",
"globex",
]);
}
async provisionTenant(tenantId: string) {
await this.runner.ensureSchema(tenantId);
}
}Hint Schema-based multi-tenancy is currently only supported on PostgreSQL. MySQL and SQLite return an
UnsupportedError.
Tenant Query Strategy
When using schema-based isolation, Stingerloom needs to route queries to the correct tenant schema. Two strategies are available, configured via the tenantStrategy option.
What is a "round-trip"?
A round-trip is a single request-response cycle between your application and the PostgreSQL server over the network. Each round-trip incurs at least one network latency cost.
For example, if your database is in another region with 10ms latency, 5 round-trips = 50ms overhead before you even get your data back. In a nearby region with 1ms latency, it's 5ms — still significant when multiplied across hundreds of concurrent tenant reads.
"search_path" (Default)
Sets search_path inside a transaction before each tenant read. Safe, but requires 5 round-trips:
App PostgreSQL
│ │
├─── 1. connect ─────────────────────►│
│◄── ack ──────────────────────────────┤
├─── 2. BEGIN ───────────────────────►│
│◄── ack ──────────────────────────────┤
├─── 3. SET LOCAL search_path ──────►│
│◄── ack ──────────────────────────────┤
├─── 4. SELECT * FROM "users" ──────►│
│◄── rows ─────────────────────────────┤
├─── 5. COMMIT ──────────────────────►│
│◄── ack ──────────────────────────────┤await em.register({
type: "postgres",
// ...
tenantStrategy: "search_path", // default — can be omitted
});"schema_qualified"
Prefixes table names with the tenant schema directly (e.g. "acme_corp"."users"). No transaction needed — just 2 round-trips:
App PostgreSQL
│ │
├─── 1. connect ─────────────────────►│
│◄── ack ──────────────────────────────┤
├─── 2. SELECT * FROM "acme_corp"."users" ►│
│◄── rows ─────────────────────────────┤await em.register({
type: "postgres",
// ...
tenantStrategy: "schema_qualified",
});With this strategy, a query like em.find(User) inside MetadataContext.run("acme_corp", ...) generates:
SELECT "id", "name" FROM "acme_corp"."users"
-- instead of: BEGIN; SET LOCAL search_path = "acme_corp"; SELECT ...; COMMIT;Comparison
| Scenario | search_path | schema_qualified |
|---|---|---|
| Tenant read (round-trips) | 5 | 2 |
| Non-tenant read | 2 | 2 |
| Write operations | Transaction (unchanged) | Transaction (unchanged) |
| PG + query timeout | Transaction | Transaction |
| MySQL / SQLite | No effect | No effect |
When to choose
schema_qualified: If your application is read-heavy with many tenant-scoped queries,schema_qualifiedcan significantly reduce latency by avoiding unnecessary transactions. With 10ms network latency, a single tenant read goes from 50ms overhead to 20ms. Both strategies produce identical results — the difference is purely in performance.
Programmatic Access
The strategy classes are exported for advanced use cases such as custom middleware or testing.
import {
TenantQueryStrategy,
SearchPathStrategy,
SchemaQualifiedStrategy,
} from "@stingerloom/orm";Direct Use of Layered Metadata
In most cases, MetadataContext.run() is sufficient, but when you need to directly override metadata per tenant, use LayeredMetadataStore.
import { LayeredMetadataStore } from "@stingerloom/orm";
const store = new LayeredMetadataStore();
// Add a tenant layer
store.addLayer("enterprise", false);
// Override metadata for a specific tenant
store.setContext("enterprise");
store.set("User", {
tableName: "enterprise_users", // Use a different table for this tenant only
});Important Notes
Do not use global state — Access metadata through layers instead of global singletons.
The Public layer is read-only — Metadata registered via decorators like @Entity and @Column is stored in the Public layer. Per-tenant modifications can only be made in separate layers.
Context scope — Outside MetadataContext.run() blocks, the context automatically reverts to "public".
Example Project
A complete NestJS multi-tenancy example is available in examples/nestjs-multitenant/.
cd examples/nestjs-multitenant
pnpm install
pnpm startNext Steps
- Configuration Guide — Operational settings like pooling, Read Replica, etc.
- Migrations — Production schema management
- Advanced Features — Event system, N+1 detection, performance optimization