Tenancy
Multi-tenant request handling: an HTTP extension resolves the tenant from each incoming request (header, subdomain, path prefix, or your own strategy) and enters a tenant scope, making the tenant ambiently available to services, guards, and per-tenant configuration for the rest of the request.
Contract
@modularityjs/tenancy defines the tenant model and the ambient TenantContext:
interface Tenant {
readonly id: string;
// Hydrated tenant metadata (name, plan, …) — shape is the app's.
readonly attributes: Record<string, unknown>;
}
class TenantContext {
getTenant(): Tenant | undefined;
getTenantId(): string | undefined;
// Throws TenantRequiredException when no tenant is in scope.
requireTenant(): Tenant;
// Enters a tenant scope for fn — the non-HTTP counterpart of the
// http-tenancy request wrap.
runWithTenant<T>(tenant: Tenant, fn: () => T | Promise<T>): Promise<T>;
}TenantContext reads the tenant synchronously from the scope chain (the level whose level equals TenancyConfig.scopeLevel), so it works anywhere inside a request wrapped by http-tenancy — or inside runWithTenant for CLI commands, queue consumers, and tests.
Configuration
TenancyModule.forRoot() configures resolution semantics:
import { TenancyModule } from '@modularityjs/tenancy';
TenancyModule.forRoot({ required: true });| Option | Default | Description |
|---|---|---|
scopeLevel | 'tenant' | Scope level name used for the tenant scope |
required | false | Reject entry points that cannot resolve a tenant |
defaultTenantId | — | Fallback tenant id when unresolved — mutually exclusive with required: true (contradiction fails boot) |
When resolution is required but fails, a TenantRequiredException is thrown. It extends NotFoundException (HTTP 404): the tenant is part of the request's addressing — an unknown subdomain, not an auth failure.
TenantsProvider — optional lookup seam
abstract class TenantsProvider {
abstract findById(id: string): Promise<Tenant | undefined>;
}TenantsProvider is an optional validation/hydration seam — deliberately not a boot-time contract. When the app binds an implementation (via preferences), every resolved tenant id is looked up through it: an unknown id counts as unresolved, and the returned attributes hydrate the Tenant. When absent, resolved ids become bare { id, attributes: {} } tenants.
import { Inject, Injectable } from '@modularityjs/di';
import { Module } from '@modularityjs/modularity';
import { TenancyModule, TenantsProvider } from '@modularityjs/tenancy';
import type { Tenant } from '@modularityjs/tenancy';
@Injectable()
class DatabaseTenantsProvider extends TenantsProvider {
async findById(id: string): Promise<Tenant | undefined> {
const row = await this.repository.findOneBy({ slug: id });
if (!row) return undefined;
return { id: row.slug, attributes: { name: row.name, plan: row.plan } };
}
}
@Module({
name: 'app-tenants',
imports: [TenancyModule],
providers: [DatabaseTenantsProvider],
preferences: [
{ provide: TenantsProvider, useClass: DatabaseTenantsProvider },
],
})
class AppTenantsModule {}HTTP Integration
@modularityjs/http-tenancy wires tenant resolution into HTTP requests through the abstract HttpServer contract — no driver-specific imports. It wraps every request: resolvers run at onRequest (only headers, URL, and connection data are available — never a parsed body), and once a tenant is resolved the request proceeds inside the tenant scope, so TenantContext, guards, handlers, and per-tenant config resolution all see it.
import { HttpModule } from '@modularityjs/http';
import { HttpFastifyModule } from '@modularityjs/http-fastify';
import { TenancyModule } from '@modularityjs/tenancy';
import {
HttpTenancyModule,
TenantResolversPool,
} from '@modularityjs/http-tenancy';
import { SubdomainTenantResolver } from '@modularityjs/http-tenancy';
import { Module } from '@modularityjs/modularity';
@Module({
name: 'app-tenancy',
imports: [HttpTenancyModule],
pools: [
{
pool: TenantResolversPool,
key: 'subdomain',
useClass: SubdomainTenantResolver,
},
],
})
class AppTenancyModule {}
const modules = [
HttpModule.forRoot({ port: 3000 }),
HttpFastifyModule,
TenancyModule.forRoot({ required: true }),
HttpTenancyModule.forRoot({ baseDomain: 'example.com' }),
AppTenancyModule,
];Resolvers
Tenant-resolution strategies are contributed to TenantResolversPool. The extension declares the pool but registers nothing by default — the app decides which strategies apply. Resolvers run in pool order; the first one returning an id wins. A resolver that throws emits a ModularityJsHttpTenantResolverError warning and the chain continues.
Three opt-in built-ins ship with the package, configured via HttpTenancyModule.forRoot():
| Resolver | Strategy | Config knob |
|---|---|---|
HeaderTenantResolver | Reads the tenant id from a request header | headerName (default 'x-tenant-id'; must be lowercase) |
SubdomainTenantResolver | acme.example.com → acme | baseDomain (unset by default — the resolver matches nothing until set) |
PathPrefixTenantResolver | /t/acme/orders → acme | pathPrefix (default '/t') |
The subdomain resolver only accepts a single label — a.b.example.com is not a tenant host, and the apex domain resolves nothing.
Custom strategies implement HttpTenantResolver:
import { Injectable } from '@modularityjs/di';
import type { HttpRequest } from '@modularityjs/http';
import type { HttpTenantResolver } from '@modularityjs/http-tenancy';
@Injectable()
class QueryTenantResolver implements HttpTenantResolver {
async resolve(request: HttpRequest): Promise<string | undefined> {
const url = new URL(request.url, 'http://placeholder');
return url.searchParams.get('tenant') ?? undefined;
}
}@Tenant() Decorator
Injects the resolved Tenant (or undefined for a tenantless request) into a handler parameter. The decorator lives in @modularityjs/http-tenancy; the value type comes from @modularityjs/tenancy:
import { Injectable } from '@modularityjs/di';
import { Get } from '@modularityjs/http';
import { Tenant } from '@modularityjs/http-tenancy';
import type { Tenant as TenantValue } from '@modularityjs/tenancy';
@Injectable()
class ReportsController {
@Get('/report')
report(@Tenant() tenant: TenantValue | undefined) {
return { tenant: tenant?.id ?? 'public' };
}
}With TenancyConfig.required: true every handler is guaranteed a tenant — unresolvable requests are rejected with 404 before reaching the route.
Per-tenant configuration
Because the request proceeds inside a scope level named TenancyConfig.scopeLevel, scope-aware config sources (config-scope, config-env-scope) resolve values per tenant automatically — a config lookup during the request walks the scope chain and can return tenant-specific overrides without any extra wiring in the handler.
Usage in services
import { Inject, Injectable } from '@modularityjs/di';
import { TenantContext } from '@modularityjs/tenancy';
@Injectable()
class OrderService {
constructor(
@Inject(TenantContext) private readonly tenantContext: TenantContext,
) {}
async listOrders(): Promise<Order[]> {
const tenant = this.tenantContext.requireTenant();
return this.repository.findBy({ tenantId: tenant.id });
}
}Outside HTTP: runWithTenant
CLI commands, queue consumers, schedulers, and tests enter a tenant scope explicitly:
await this.tenantContext.runWithTenant(
{ id: 'acme', attributes: {} },
async () => {
// TenantContext.getTenant() returns the acme tenant here,
// and per-tenant config resolution applies.
await this.orderService.listOrders();
},
);