Scope System
Overview
The scope system (@modularityjs/scope) provides per-request context isolation using Node.js AsyncLocalStorage. It enables multi-tenant applications, per-request configuration, and any pattern where services need to behave differently based on the current execution context.
Core Concepts
Scope Level
A scope level is a single dimension of context:
interface ScopeLevel {
level: string; // e.g. 'tenant', 'workspace', 'user'
id: string; // e.g. 'acme', 'ws-123', 'user-456'
}Scope Chain
A scope chain is an ordered array of scope levels, from most specific to least specific:
type ScopeChain = ScopeLevel[];
// Example: tenant > workspace > user
const chain: ScopeChain = [
{ level: 'user', id: 'user-456' },
{ level: 'workspace', id: 'ws-123' },
{ level: 'tenant', id: 'acme' },
];ScopeService
The ScopeService manages scope context via AsyncLocalStorage:
@Injectable()
class RequestHandler {
constructor(@Inject(ScopeService) private readonly scope: ScopeService) {}
async handleRequest(tenantId: string) {
await this.scope.runInScope({ level: 'tenant', id: tenantId }, async () => {
// Everything inside this callback runs in the tenant's scope
const current = this.scope.getCurrentScope();
console.log(current.chain); // [{ level: 'tenant', id: tenantId }]
});
}
}Key Methods
| Method | Description |
|---|---|
runInScope(leaf, fn) | Execute fn within a scope. Optionally resolves a full chain via ScopeChainResolver. |
runInChain(chain, fn) | Execute fn with an explicit scope chain. |
runInDefaultScope(fn) | Execute fn with an empty chain (no scope). |
getCurrentScope() | Returns the current ScopeContext ({ chain: ScopeChain }). |
getLeafScope() | Returns the first (most specific) scope level, or undefined. |
Scope Chain Resolver
For applications with hierarchical scopes (e.g. tenant > workspace), implement a ScopeChainResolver to automatically expand a leaf scope into a full chain:
The abstract contract is:
abstract class ScopeChainResolver {
abstract resolve(leaf: ScopeLevel): Promise<ScopeChain> | ScopeChain;
}Implementations may return either a ScopeChain synchronously or a Promise<ScopeChain> — runInScope() awaits whichever you return. Use the sync form for in-memory lookups; use the async form when the parent chain comes from a database, cache, or remote service.
@Injectable()
class TenantChainResolver extends ScopeChainResolver {
resolve(leaf: ScopeLevel): Promise<ScopeChain> | ScopeChain {
// Look up the workspace's parent tenant
if (leaf.level === 'workspace') {
const tenant = this.lookupTenant(leaf.id);
return [leaf, { level: 'tenant', id: tenant.id }];
}
return [leaf];
}
}
@Module({
name: 'my-scope',
imports: [ScopeModule],
providers: [TenantChainResolver],
preferences: [{ provide: ScopeChainResolver, useClass: TenantChainResolver }],
})
class MyScopeModule {}When runInScope() is called with a leaf scope, the chain resolver expands it into the full chain automatically.
Integration with Config
The scope system integrates with the config system via extension packages:
ConfigScopeModule— makesConfigServicescope-aware. Resolution walks the scope chain per source, allowing tenant-specific config values.ConfigEnvScopeModule— makes environment variable lookup scope-aware. Scoped env vars use the patternMODULARITYJS__SCOPES__<LEVEL>__<ID>__<PATH>.
See the Configuration guide for details.
App Setup
const app = await createApp({
di: inversify,
modules: [
ScopeModule, // scope system
ConfigModule, // config system
ConfigScopeModule, // scope-aware config
MyScopeModule, // your chain resolver
],
});