Skip to content

Tenancy Wiring

Recipes are working wiring examples with the sharp edges annotated. This page and the tenancy-wiring recipe served to coding agents by the MCP server share one source (@modularityjs/manifest), so they never drift apart.

Three layers compose for multi-tenant requests:

  • @modularityjs/scopeScopeModule + ScopeService, the AsyncLocalStorage scope chain the tenant rides on.
  • @modularityjs/tenancy — HTTP-agnostic core. TenantContext: getTenant() → Tenant | undefined, getTenantId(), requireTenant() (throws TenantRequiredException), and runWithTenant(tenant, fn) — the non-HTTP entry point for CLI commands, queue consumers, and tests. Tenant is { id, attributes }. Configure via TenancyModule.forRoot({ required, defaultTenantId, scopeLevel }).
  • @modularityjs/http-tenancy — the HTTP bridge. Wraps every request: resolvers from TenantResolversPool run at onRequest (headers, URL, and connection data only — never a parsed body); the first resolver returning an id wins; the handler then runs inside runWithTenant, so the tenant is ambient for guards, interceptors, services, and per-tenant config-scope resolution. Provides the @Tenant() parameter decorator and getRequestTenant(request).

The extension registers no resolver by default — the built-in strategies (HeaderTenantResolver reading HttpTenancyConfig.headerName, default x-tenant-id; SubdomainTenantResolver, needs baseDomain; PathPrefixTenantResolver, pathPrefix default /t) are exported unregistered, and the app contributes the ones it wants as pool entries. HttpTenancyModule.forRoot({...}) tunes those knobs.

typescript
import { Inject } from '@modularityjs/di';
import { inversify } from '@modularityjs/di-inversify';
import {
  Controller,
  Get,
  HttpControllersPool,
  HttpModule,
} from '@modularityjs/http';
import { HttpFastifyModule } from '@modularityjs/http-fastify';
import {
  HeaderTenantResolver,
  HttpTenancyModule,
  Tenant,
  TenantResolversPool,
} from '@modularityjs/http-tenancy';
import { createApp, Module, ModularityModule } from '@modularityjs/modularity';
import { ScopeModule } from '@modularityjs/scope';
import {
  TenancyModule,
  TenantContext,
  type Tenant as TenantValue,
} from '@modularityjs/tenancy';

@Controller('/reports')
class ReportsController {
  constructor(
    @Inject(TenantContext) private readonly tenantContext: TenantContext,
  ) {}

  @Get('/')
  report(@Tenant() tenant: TenantValue | undefined) {
    return { tenant: tenant?.id ?? null };
  }

  @Get('/strict')
  strict() {
    // Ambient access — works the same in any service, not just handlers.
    return { tenant: this.tenantContext.requireTenant().id };
  }
}

@Module({
  name: 'reports',
  imports: [HttpModule, HttpTenancyModule, TenancyModule],
  providers: [ReportsController],
  pools: [
    { pool: HttpControllersPool, key: 'reports', useClass: ReportsController },
    {
      pool: TenantResolversPool,
      key: 'header',
      useClass: HeaderTenantResolver,
    },
  ],
})
class AppModule {}

const app = await createApp({
  di: inversify,
  modules: [
    ModularityModule,
    ScopeModule,
    TenancyModule,
    HttpModule.forRoot({ port: 3000 }),
    HttpFastifyModule,
    // Header/PathPrefix resolvers take a client-asserted tenant id, so boot
    // requires either a TenantsProvider that validates the id, or this
    // explicit opt-in acknowledging the ids are unvalidated (dev only).
    HttpTenancyModule.forRoot({ allowUnvalidatedTenantIds: true }),
    AppModule,
  ],
});
await app.start();

In production, bind a TenantsProvider (preference on the tenancy contract) that looks the id up in your tenant store — then drop allowUnvalidatedTenantIds and boot enforces validated resolution.

Unresolved tenants are allowed by default. @Tenant() yields undefined and getTenant() returns undefined for a tenantless request. TenancyModule.forRoot({ required: true }) rejects them with TenantRequiredException instead; defaultTenantId substitutes a fallback tenant and is mutually exclusive with required (a default means resolution can never fail — config validation catches the contradiction at boot).

TenantsProvider is the optional validation seam. Bind an implementation via preferences: [{ provide: TenantsProvider, useClass: ... }] and every resolved id is hydrated through findById — an unknown id counts as unresolved. Without it, resolved ids become bare { id, attributes: {} } tenants; consume it with @InjectOptional, never as a boot-time contract. A resolver that throws is skipped with a process.emitWarning (type ModularityJsHttpTenantResolverError) and the next resolver in the pool runs.