Skip to content

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:

api/src/persistence/middlewares/tenant-schema.middleware.ts
/* 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;
}
}
}

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:

api/src/persistence/tenant-schema.context.ts
/* 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:

api/src/common/context/context-sources.ts
/* 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;

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:

api/src/common/idempotency/operation-key.ts
/* 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).