Skip to content

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:

typescript
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:

typescript
import { TenancyModule } from '@modularityjs/tenancy';

TenancyModule.forRoot({ required: true });
OptionDefaultDescription
scopeLevel'tenant'Scope level name used for the tenant scope
requiredfalseReject entry points that cannot resolve a tenant
defaultTenantIdFallback 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

typescript
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.

typescript
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.

typescript
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():

ResolverStrategyConfig knob
HeaderTenantResolverReads the tenant id from a request headerheaderName (default 'x-tenant-id'; must be lowercase)
SubdomainTenantResolveracme.example.comacmebaseDomain (unset by default — the resolver matches nothing until set)
PathPrefixTenantResolver/t/acme/ordersacmepathPrefix (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:

typescript
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:

typescript
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

typescript
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:

typescript
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();
  },
);