Several tenants in one deployment
users-api serves several tenants from one process: one PostgreSQL schema, or one SQLite file, per tenant. The tenant is set once, where the request enters, and everything downstream reads the same value. Nothing falls back to a default tenant.
1. Set the tenant where the request enters
Section titled “1. Set the tenant where the request enters”TenantSchemaMiddleware reads x-tenant-schema, refuses a missing or unknown tenant, and
runs the rest of the request inside it:
/* Copyright (C) 2026-present Aristotelis — see repository license. */
import { HEADERS } from '@common/constants/headers.constants.js';import { BadRequestException, ForbiddenException, Injectable, type NestMiddleware,} from '@nestjs/common';import { tenantSchema } from '../persistence.config.js';import { TenantSchemaContext } from '../tenant-schema.context.js';import { InvalidTenantSchemaError } from '../tenant-schema.errors.js';
@Injectable()/** * Resolves tenant schema from the incoming request header and runs the request * inside the tenant async context used by persistence components. */export class TenantSchemaMiddleware implements NestMiddleware { constructor( private readonly tenantSchemaContext: TenantSchemaContext, private readonly tenants: ReadonlySet<string>, ) {}
use( request: { headers?: Record<string, string | string[] | undefined> }, _response: unknown, next: () => void, ): void { const rawHeaderValue = request.headers?.[HEADERS.TENANT_SCHEMA]; const headerValue = Array.isArray(rawHeaderValue) ? rawHeaderValue[0] : rawHeaderValue;
if (!headerValue) { throw new ForbiddenException( 'Tenant context is required to process this request.', ); }
const schema = this.parseSchema(headerValue); if (!this.tenants.has(schema)) { throw new ForbiddenException('Unknown tenant context.'); }
this.tenantSchemaContext.run(schema, () => { next(); }); }
private parseSchema(headerValue: string): string { try { return tenantSchema(headerValue); } catch (error) { if (error instanceof InvalidTenantSchemaError) { throw new BadRequestException(error.message); } throw error; } }}2. One store for the tenant
Section titled “2. One store for the tenant”TenantSchemaContext.run sets the tenant of
@nestjs-pipeline/tenant, the one store
the database, the pipelines and @cqrs-ddd/core’s cache keys all read:
/* Copyright (C) 2026-present Aristotelis — see repository license. */
import { MissingTenantContextError } from '@cqrs-ddd/core/domain';import { Injectable } from '@nestjs/common';import { currentTenantId, runWithTenant } from '@nestjs-pipeline/tenant';import { tenantSchema } from './persistence.config.js';
/** * Validates and resolves the active tenant schema. * * It reads and writes the tenant of `@nestjs-pipeline/tenant`, the one tenant * store the database store, the pipeline and core's tenant-scoped cache keys * share. It fails closed: work outside {@link run} or a pipeline execution has * no tenant, and reading {@link schema} there throws instead of falling back to * a default tenant. */@Injectable()export class TenantSchemaContext { /** * Runs `callback` with `schema` as the active tenant. * * @throws {MissingTenantContextError} When `schema` is `undefined`, as in a * job payload that carries no tenant. * @throws {InvalidTenantSchemaError} When `schema` is not a valid name. * * @example * ```ts * await tenantContext.run(job.data.tenant, () => this.process(job)); * ``` */ run<T>(schema: string | undefined, callback: () => T): T { if (schema === undefined) { throw new MissingTenantContextError('a tenant-scoped run'); } return runWithTenant(tenantSchema(schema), callback); }
/** * The active tenant, validated on every read: the tenant store also accepts * tenants set through `runWithTenant` directly. * * @throws {MissingTenantContextError} Outside {@link run} and outside a * pipeline execution that has a tenant. * @throws {InvalidTenantSchemaError} When the active tenant is not a valid * name. * * @example * ```ts * const tenant = tenantContext.schema; * ``` */ get schema(): string { const schema = currentTenantId(); if (schema === undefined) { throw new MissingTenantContextError('reading the active tenant'); } return tenantSchema(schema); }}Pipelines and jobs take the tenant, and the correlation id, from the sources registered once:
/* Copyright (C) 2026-present Aristotelis — see repository license. */
import { correlationSource } from '@nestjs-pipeline/correlation';import { tenantSource } from '@nestjs-pipeline/tenant';
/** * Where pipelines and jobs read and restore the tenant and correlation id: * the stores of `@nestjs-pipeline/tenant` and `@nestjs-pipeline/correlation`. */export const contextSources = { tenantId: tenantSource, correlationId: correlationSource,} as const;3. Put the tenant in every key
Section titled “3. Put the tenant in every key”A cache hit or an idempotent replay skips the handler, and with it the handler’s checks. So every key that can return a stored result carries the tenant, and the principal where the result depends on it. The partitioned key factories of the add-ons build such keys and fail closed when a segment is missing; this is the operation key of the application’s idempotent commands:
/* Copyright (C) 2026-present Aristotelis — see repository license. */
import { getSessionPrincipal } from '@common/context/session-principal.store.js';import { principalSegments } from '@common/types/session-principal.js';import type { IPipelineContext } from '@nestjs-pipeline/core';import { createPartitionedIdempotencyKeyFactory, type IdempotencyKeyFactory,} from '@nestjs-pipeline/idempotency';
/** * Namespace version for operation keys. Bump it only deliberately: a new * namespace abandons the deduplication claims stored under the previous one, so * an operation already completed can execute again until the old records expire. */const OPERATION_KEY_VERSION = 'v1';
/** * Key factory for the client-chosen identity of one operation. * * `operationId` reads the `Idempotency-Key` the client sent: retries of one * operation carry the same value and replay its result, a new operation carries * a new value and runs. Without one the request is not deduplicated, and domain * invariants (such as a unique email) answer a duplicate. A business identifier * must not serve as the operation id: deleting and recreating that object would * replay the deleted object's creation. * * The key is `v1:<tenant>:<principalType>:<principalId>:<action>:<operationId>`, * each segment escaped, and fails closed when the tenant or the session * principal is missing. It carries nothing about permissions — an operation key * that changed when permissions changed would let the same side effect run a * second time. Bind replay to the caller's authorization with * `requireAbilityDigest` of `@nestjs-pipeline/casl` instead. * * @example * ```ts * idempotent({ * keyFactory: operationIdempotencyKeyFactory( * 'user.create', * (ctx) => (ctx.request as CreateUserCommand).idempotencyKey, * ), * replayScopeFactory: requireAbilityDigest, * }); * ``` */export function operationIdempotencyKeyFactory( action: string, operationId: (ctx: IPipelineContext) => string | undefined,): IdempotencyKeyFactory { return createPartitionedIdempotencyKeyFactory({ version: OPERATION_KEY_VERSION, action, principal: () => principalSegments(getSessionPrincipal()), operation: operationId, onMissingOperation: 'skip', });}The rate limits partition the same way, per tenant and principal
(createPartitionedRateLimitKeyFactory), and repository cache keys take the tenant through
the resolver ObservabilityModule registers (see Wiring an
application).