Tenancy Wiring
Recipes are working wiring examples with the sharp edges annotated. This page and the
tenancy-wiringrecipe 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/scope—ScopeModule+ScopeService, theAsyncLocalStoragescope chain the tenant rides on.@modularityjs/tenancy— HTTP-agnostic core.TenantContext:getTenant() → Tenant | undefined,getTenantId(),requireTenant()(throwsTenantRequiredException), andrunWithTenant(tenant, fn)— the non-HTTP entry point for CLI commands, queue consumers, and tests.Tenantis{ id, attributes }. Configure viaTenancyModule.forRoot({ required, defaultTenantId, scopeLevel }).@modularityjs/http-tenancy— the HTTP bridge. Wraps every request: resolvers fromTenantResolversPoolrun atonRequest(headers, URL, and connection data only — never a parsed body); the first resolver returning an id wins; the handler then runs insiderunWithTenant, so the tenant is ambient for guards, interceptors, services, and per-tenant config-scope resolution. Provides the@Tenant()parameter decorator andgetRequestTenant(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.
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.