Skip to content

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:

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

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

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

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

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

typescript
@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 — makes ConfigService scope-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 pattern MODULARITYJS__SCOPES__<LEVEL>__<ID>__<PATH>.

See the Configuration guide for details.

App Setup

typescript
const app = await createApp({
  di: inversify,
  modules: [
    ScopeModule, // scope system
    ConfigModule, // config system
    ConfigScopeModule, // scope-aware config
    MyScopeModule, // your chain resolver
  ],
});