Databaseverbindingen per tenantschema
Wissel dynamisch tussen databaseschema's of verbindingen op basis van de gevonden tenant.
Databaseverbindingen per tenantschema is een gratis Enterprise-backend-API's met NestJS-les op CoddyKit. Dit is les 2 van 4. Je kunt de volledige les hieronder gratis lezen en daarna in de browser praktisch oefenen met een ingebouwde code-editor en een AI-begeleider die 24/7 beschikbaar is. Deze les maakt deel uit van het leertraject Enterprise-backend-API's met NestJS. Je voortgang wordt gesynchroniseerd op het web en in de CoddyKit-app. De cursus Enterprise-backend-API's met NestJS bevat in totaal 4 lessen.
Schema per tenant: het grote geheel
In een API voor meerdere tenants moeten de gegevens van elke tenant geïsoleerd blijven. Het model schema per tenant gebruikt één fysieke database, maar geeft elke tenant een eigen PostgreSQL-schema (bijvoorbeeld tenant_acme en tenant_globex). Tabellen hebben in alle schema's dezelfde structuur.
- Tabelpool (gedeeld schema): één set tabellen, met isolatie via een kolom
tenant_id. Eenvoudig, maar een lek is slechts één vergeten WHERE-clausule verwijderd. - Schema per tenant: sterkere isolatie en eenvoudigere back-ups per tenant, maar je moet het actieve schema per request wisselen.
- Database per tenant: maximale isolatie, maar de hoogste operationele kosten.
Deze les richt zich op het dynamisch routeren van elk request naar het juiste schema of de juiste verbinding nadat de tenant is vastgesteld.
De tenant per request bepalen
Voordat je van schema kunt wisselen, heb je de tenant nodig. Je bepaalt deze meestal via een subdomein, header of JWT-claim. Lichtgewicht middleware haalt de tenant eruit en voegt deze toe aan het request, zodat providers verderop hem kunnen uitlezen.
Houd het bepalen hier eenvoudig en goedkoop; controleer of de tenant bestaat wanneer je de verbinding opbouwt.
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class TenantMiddleware implements NestMiddleware {
use(req: Request, _res: Response, next: NextFunction) {
// Prefer an explicit header; fall back to subdomain.
const headerTenant = req.headers['x-tenant-id'] as string | undefined;
const host = req.headers.host ?? '';
const subdomain = host.split('.')[0];
const tenantId = headerTenant ?? subdomain;
(req as any).tenantId = tenantId;
next();
}
}Waarom REQUEST-scope de natuurlijke keuze is
Het actieve schema verandert bij elk request. NestJS-providers zijn standaard singletons, dus een singleton kan niet veilig een schema per request bevatten. De nette oplossing is een request-scoped provider die het huidige REQUEST ontvangt.
Scope.REQUESTmaakt voor elk binnenkomend request een nieuwe providerinstantie.- Elke provider die een request-scoped provider injecteert, wordt zelf ook request-scoped (dit werkt omhoog door de keten).
- Afweging: het aanmaken van een instantie per request brengt overhead met zich mee. Houd request-scoped providers daarom klein en cache de zware onderdelen (verbindingen) buiten de scope.
Van schema wisselen met SET search_path
De eenvoudigste manier om op één Postgres-verbinding van schema te wisselen is SET search_path. Hiermee geef je Postgres aan tegen welk schema niet-gekwalificeerde tabelnamen gedurende de rest van die sessie moeten worden opgelost.
Kritieke kanttekening: met een verbindingspool kan een verbinding vervolgens aan het request van een andere tenant worden gegeven. Je moet de wissel beperken tot de betreffende bewerking en daarna resetten, of een transactiegebonden variant gebruiken.
import { DataSource } from 'typeorm';
export async function withTenantSchema<T>(
dataSource: DataSource,
schema: string,
work: () => Promise<T>,
): Promise<T> {
const runner = dataSource.createQueryRunner();
await runner.connect();
try {
// SET LOCAL is transaction-scoped and auto-resets on commit/rollback.
await runner.startTransaction();
await runner.query('SET LOCAL search_path TO $1', [schema]);
const result = await work();
await runner.commitTransaction();
return result;
} catch (err) {
await runner.rollbackTransaction();
throw err;
} finally {
await runner.release();
}
}De schemanaam opschonen
S tomanamen zijn identifiers, geen waarden. Je kunt een identifier in SET search_path niet veilig parametriseren zoals je gegevens parametriseert. Een kwaadaardige of ongeldige tenant-id kan SQL-injectie veroorzaken.
Valideer het vastgestelde schema altijd tegen een strikt patroon met toegestane waarden (en bij voorkeur ook tegen een register van bekende tenants) voordat je het invoegt.
const SCHEMA_PATTERN = /^[a-z][a-z0-9_]{1,62}$/;
export function tenantSchema(tenantId: string): string {
const candidate = `tenant_${tenantId.toLowerCase()}`;
if (!SCHEMA_PATTERN.test(candidate)) {
throw new Error(`Invalid tenant schema: ${candidate}`);
}
return candidate;
}
console.log(tenantSchema('Acme')); // tenant_acme
try {
tenantSchema('acme; DROP SCHEMA x'); // throws
} catch (e) {
console.log((e as Error).message);
}Verbindingen per tenant in plaats van search_path
Een alternatief voor het wijzigen van search_path in een gedeelde pool is om een toegewezen DataSource (en pool) per tenantschema te behouden. Elke DataSource wordt één keer geconfigureerd met zijn schema en daarna hergebruikt.
- Voordeel: geen schemamutatie per request en geen risico op kruisbesmetting tussen pools.
- Nadeel: het aantal verbindingen vermenigvuldigt zich met het aantal actieve tenants — je moet de poolgroottes begrenzen en inactieve tenants verwijderen.
Hier komt een verbindingsbeheerder die DataSources lui opbouwt en cachet goed van pas.
Een verbindingsbeheerder voor tenants
De beheerder beheert de levensduur: hij bouwt een DataSource op wanneer een tenant voor het eerst wordt gezien, cachet deze en hergebruikt hem daarna. Het is een singleton — alleen het opzoeken gebeurt per request; de zware verbindingen kunnen veilig worden gedeeld omdat elke verbinding aan zijn eigen schema is gekoppeld.
Let erop dat de cachesleutel het schema is en dat gelijktijdige eerste aanvragen niet tweemaal mogen bouwen (sla de promise op, niet alleen de opgeloste waarde).
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
@Injectable()
export class TenantConnectionManager {
private readonly pools = new Map<string, Promise<DataSource>>();
get(schema: string): Promise<DataSource> {
let pool = this.pools.get(schema);
if (!pool) {
pool = this.build(schema);
this.pools.set(schema, pool); // cache the promise to dedupe races
}
return pool;
}
private async build(schema: string): Promise<DataSource> {
const ds = new DataSource({
type: 'postgres',
url: process.env.DATABASE_URL,
schema,
entities: [__dirname + '/**/*.entity.{ts,js}'],
poolSize: 5,
});
await ds.initialize();
return ds;
}
}De DataSource van de tenant als provider beschikbaar maken
Verbind nu een request-scoped factoryprovider die de tenant uit REQUEST leest, het schema ervan berekent en de beheerder om de juiste DataSource vraagt. Services injecteren dit token in plaats van een vaste verbinding.
Omdat de factory REQUEST injecteert, is de provider request-scoped — maar de beheerder die hij aanroept is een singleton, waardoor verbindingen hergebruikt blijven.
import { Scope, Provider } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
import { DataSource } from 'typeorm';
export const TENANT_DATA_SOURCE = 'TENANT_DATA_SOURCE';
export const tenantDataSourceProvider: Provider = {
provide: TENANT_DATA_SOURCE,
scope: Scope.REQUEST,
inject: [REQUEST, TenantConnectionManager],
useFactory: (req: Request, manager: TenantConnectionManager): Promise<DataSource> => {
const tenantId = (req as any).tenantId as string | undefined;
if (!tenantId) {
throw new Error('No tenant resolved for this request');
}
const schema = tenantSchema(tenantId);
return manager.get(schema);
},
};De DataSource van de tenant in een service gebruiken
Een request-scoped service injecteert de opgeloste DataSource via een token. Elke query die de service uitvoert, richt zich al op het juiste schema — er is geen tenant_id-filter en geen handmatige schemavissel in de bedrijfslogica.
De factory retourneert een Promise<DataSource>, dus gebruik await erop (of laat de factory wachten voordat deze retourneert) voordat je repositories opent.
import { Inject, Injectable, Scope } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Invoice } from './invoice.entity';
@Injectable({ scope: Scope.REQUEST })
export class InvoiceService {
constructor(
@Inject(TENANT_DATA_SOURCE) private readonly dataSource: DataSource,
) {}
findAll(): Promise<Invoice[]> {
// Already bound to tenant_<x> schema — no tenant filter needed.
return this.dataSource.getRepository(Invoice).find();
}
}Dynamische modules voor configureerbare tenancy
Herbruikbare tenancylogica hoort in een dynamische module, zodat apps de oplossingsstrategie, schemaprefix en poolgrootte kunnen configureren via forRoot/forRootAsync. De module exporteert de beheerder en de request-scoped DataSource-provider.
Dit is de combinatie van multi-tenancy en dynamische modules: de configuratie is statisch (eenmalig ingesteld bij het opstarten), terwijl de opgeloste verbinding dynamisch is (per request).
import { DynamicModule, Module } from '@nestjs/common';
export interface TenancyOptions {
schemaPrefix: string;
poolSize: number;
}
@Module({})
export class TenancyModule {
static forRoot(options: TenancyOptions): DynamicModule {
return {
module: TenancyModule,
global: true,
providers: [
{ provide: 'TENANCY_OPTIONS', useValue: options },
TenantConnectionManager,
tenantDataSourceProvider,
],
exports: [TenantConnectionManager, TENANT_DATA_SOURCE],
};
}
}Levensduur, verwijdering en migraties
Pools per tenant vormen een bronlek dat staat te gebeuren. Beheer ze bewust:
- Beperk gelijktijdigheid: gebruik een kleine
poolSizeper tenant; veel tenants × grote pools putten Postgresmax_connectionsuit. - Verwijder inactieve tenants: houd bij wanneer een tenant voor het laatst is gebruikt en roep
destroy()aan voor DataSources die inactief worden (een LRU houdt het geheugengebruik begrensd). - Sluit netjes af: implementeer
OnModuleDestroyom elke gecachete DataSource te sluiten. - Migraties: een nieuwe tenant betekent
CREATE SCHEMAplus het uitvoeren van migraties daarop vóór het eerste gebruik; voer bij een implementatie migraties uit voor alle tenantschema's.
Behandel het aanmaken van schema's als een expliciete stap bij het onboarden, nooit als een toevallig gevolg van de eerste query.
Korte controle: lekken tussen tenants voorkomen
Je wisselt van schema met SET search_path op verbindingen die je uit een gedeelde TypeORM-pool leent. Soms ziet tenant A de rijen van tenant B. Wat is waarschijnlijk de oorzaak en wat is de juiste oplossing?
Samenvatting: dynamische schemaroutering
Je hebt geleerd om elk request naar het juiste tenantschema te routeren:
- Bepaal de tenant vroeg (header/subdomein/JWT) in middleware en voeg deze toe aan het request.
- Wissel van schema via transactiegebonden
SET LOCAL search_pathop een gedeelde pool, of via een toegewezen DataSource per tenant die wordt gecachet in een singletonbeheerder. - Verbind een request-scoped factoryprovider die
REQUESTleest, de schemanaam valideert en de juiste DataSource retourneert — services blijven onafhankelijk van tenants. - Configureer alles via een dynamische module (
forRoot), waarbij de configuratie statisch blijft en de verbinding dynamisch is. - Beheer alles veilig: valideer identifiers, begrens en verwijder pools, sluit ze af bij het afsluiten en maak schema's aan en migreer ze als expliciete stap bij het onboarden.
Leer TypeScript met een AI-tutor — gratis
Schrijf echte code en voer die uit in je browser, krijg direct hulp van een AI-tutor die 24/7 beschikbaar is en ga verder waar je gebleven bent op het web of in de app.
- Cursussen
- 20
- Lessen
- 76
Veelgestelde vragen
Is de les “Databaseverbindingen per tenantschema” gratis?
Ja — de volledige tekst van “Databaseverbindingen per tenantschema” kun je hier gratis op het web lezen. Als je interactief wilt oefenen met een ingebouwde code-editor en een AI-begeleider die 24/7 beschikbaar is, en de rest van de cursus Enterprise-backend-API's met NestJS wilt ontgrendelen, kun je upgraden naar CoddyKit PRO. De cursus Enterprise-backend-API's met NestJS bevat in totaal 4 lessen.
Wat leer ik in “Databaseverbindingen per tenantschema”?
Wissel dynamisch tussen databaseschema's of verbindingen op basis van de gevonden tenant. Je oefent met Enterprise-backend-API's met NestJS door code rechtstreeks in de browser uit te voeren. Een AI-begeleider die 24/7 beschikbaar is beantwoordt je vragen terwijl je de les doorwerkt.
Heb ik ervaring nodig om met Enterprise-backend-API's met NestJS te beginnen?
Ervaring vooraf is niet nodig. Enterprise-backend-API's met NestJS op CoddyKit is opgebouwd voor beginners tot gevorderden, zodat je hier of bij het begin kunt starten en in je eigen tempo kunt leren. Dit is les 2 van 4.
Hoe lang duurt de les “Databaseverbindingen per tenantschema”?
De meeste lessen van CoddyKit duren ongeveer 5–10 minuten. Elke les is kort en interactief, zodat je gestaag vooruitgaat en op het web en in de app precies verdergaat waar je was gebleven.
Kan ik code schrijven en uitvoeren in deze les over Enterprise-backend-API's met NestJS?
Ja. Elke les over Enterprise-backend-API's met NestJS bevat een ingebouwde code-editor, zodat je rechtstreeks in je browser echte code kunt schrijven en uitvoeren en direct feedback van AI krijgt — lokale installatie is niet nodig.
Alle lessen in deze cursus
- Tenantresolutie via middleware en AsyncLocalStorage
- Databaseverbindingen per tenantschema
- Configureerbare dynamische modules bouwen
- Request-scoped providers en hun afwegingen