Contextual Injection
Contextual injection lets a single token resolve to different providers depending on runtime context — the current user, the active tenant, the environment, or a feature flag. Same dependency, different implementation, chosen per request or per scope.
The whole subsystem lives in
packages/titan/src/nexus/context.ts.
It has four moving parts:
ContextProvider— a key/value bag of context values (get/set/has/…), with parent-chained inheritance.ContextManager— owns the activeContextProvider(isolated per async flow viaAsyncLocalStorage) and the registry of resolution strategies.ResolutionStrategy—{ name, applies(token, ctx), select(providers, ctx) }; given a candidate array of providers and aResolutionContext, it picks one.ContextAwareProvider—{ provide(ctx), canProvide?(ctx) }; a provider whose value is computed from the resolution context.
import {
ContextKeys,
InjectContext,
TenantStrategy,
ContextManager,
createContextKey,
getContextManager,
RoleBasedStrategy,
EnvironmentStrategy,
FeatureFlagStrategy,
DefaultContextProvider,
createContextAwareProvider,
type ContextKey,
type ContextProvider,
type ResolutionStrategy,
type ContextAwareProvider,
} from '@omnitron-dev/titan/nexus';
When you need it
Three scenarios that all reduce to "this dependency depends on who is asking":
- Multi-tenancy. Tenant A uses a different storage backend than tenant B.
- Feature flags. New users get the new payment processor; old users get the legacy one.
- Per-role behaviour. Admins see audit-aware queries; regular users see plain ones.
Without contextual injection, you would write
if (tenant === 'X') { useA() } else { useB() } in every consumer.
Contextual injection moves the decision into the container.
How resolution threads context
Two facts about this flow are worth pinning down up front:
- Context values live on a
ContextProvider, not on theContextManager.ContextManager.getCurrentContext()returns the activeContextProvider, andget/set/has/delete/clear/keys/toObject/createChildare all methods on that object (theContextProviderinterface innexus/context.ts). - Strategy selection is an explicit call. A strategy never runs
"automatically" on a bare
container.resolve(token). The code that has several candidate providers for a token callsContextManager.selectProvider(token, providers, resolutionContext), which walks the registered strategies and returns one provider (ContextManager.selectProvider). TheResolutionContext.metadatait reads is built bycreateResolutionContext, which flattens the currentContextProviderinto a plain object viatoObject()(createResolutionContext).
The four building blocks:
ContextProvider— the per-flow key/value bag.ContextKey<T>— typed key for one piece of context.ResolutionStrategy— maps(candidate providers, context) → one provider.ContextAwareProvider— a provider whose value is computed from the resolution context.
Built-in strategies
| Strategy | What it reads |
|---|---|
TenantStrategy | Tenant id from context |
RoleBasedStrategy | Current user's role |
EnvironmentStrategy | NODE_ENV / config-driven env |
FeatureFlagStrategy | Flag query |
These compose with the ContextManager to drive resolution.
Defining a context key
import { createContextKey } from '@omnitron-dev/titan/nexus';
const TENANT_ID = createContextKey<string>('tenant.id');
const USER_ROLE = createContextKey<string>('user.role');
ContextKeys is a registry of pre-defined keys the framework uses
(see source for the canonical set).
Setting context per request
The ContextManager owns the strategy registry and the active
context. The key/value accessors —
get/set/has/delete/clear/keys/toObject/createChild —
live on the ContextProvider returned by getCurrentContext(),
so request middleware reaches through the manager to set values:
import { ContextManager, ContextKeys } from '@omnitron-dev/titan/nexus';
@Injectable()
class TenantMiddleware {
constructor(private readonly contextManager: ContextManager) {}
async handle(ctx: any, next: () => Promise<any>) {
const tenantId = ctx.headers.get('x-tenant-id');
// Set on the active ContextProvider, not on the manager itself.
this.contextManager.getCurrentContext().set(ContextKeys.Tenant, {
id: tenantId,
name: tenantId,
});
return next();
}
}
To bind a context to one async flow — so concurrent requests don't
see each other's values — wrap the request handler in
runWithContext. The active ContextProvider is stored in an
AsyncLocalStorage frame, so every getCurrentContext() call inside
the callback (including across awaits) sees the same provider
(ContextManager.runWithContext):
const requestContext = contextManager.createScopedContext();
requestContext.set(ContextKeys.Tenant, { id: 'acme', name: 'Acme' });
await contextManager.runWithContext(requestContext, async () => {
// Everything here resolves against the `acme` tenant context.
await handleRequest();
});
The Nexus
Containerholds its ownContextManagerand a rootContextProvider.container.getContext()returns that provider andcontainer.withContext(fn)runsfninside the container's context frame (Container.getContext/Container.withContext) — convenient when you are wiring context outside an HTTP layer.
End-to-end: context-driven provider selection
Here is the full loop, mirroring the integration test in
test/nexus/context/context.spec.ts. We populate a request-scoped
context, run a handler inside it, build a ResolutionContext, and let
the strategies pick the right provider from a candidate array:
import {
ContextManager,
ContextKeys,
Scope,
} from '@omnitron-dev/titan/nexus';
const manager = new ContextManager(); // default strategies pre-registered
// 1. Build the request context and fill it with the facts strategies read.
const requestContext = manager.createScopedContext();
requestContext.set(ContextKeys.User, { id: '1', name: 'Ada', roles: ['admin'] });
requestContext.set(ContextKeys.Tenant, { id: 'tenant1', name: 'Acme' });
requestContext.set(ContextKeys.Environment, 'production');
requestContext.set(ContextKeys.Features, ['newUI']);
// 2. The candidate providers for one logical token. Each carries the
// discriminator its strategy looks at (environment / tenant / feature /
// requiredRole). These are plain descriptors, not container registrations.
const providers = [
{ environment: 'development', value: 'dev-service' },
{ environment: 'production', value: 'prod-service' },
];
// 3. Run the handler inside the context, then select.
const chosen = await manager.runWithContext(requestContext, async () => {
const resolutionContext = manager.createResolutionContext(
/* container */ undefined,
Scope.Request,
);
// resolutionContext.metadata now contains { user, tenant, environment,
// features, ... } flattened from the current ContextProvider.
return manager.selectProvider(SOME_TOKEN, providers, resolutionContext);
});
chosen.value; // 'prod-service' — EnvironmentStrategy matched 'production'
Walking ContextManager.selectProvider:
selectProvidershort-circuits when there are 0 or 1 candidates.- Otherwise it iterates strategies in registration order
(
environment, thenfeature-flag, thentenant, thenrole-basedby default) and callsapplies(token, ctx)on each. - The first strategy whose
appliesreturnstruerunsselect(providers, ctx); if that returns a truthy provider, it wins. HereEnvironmentStrategy.appliesis true (metadata hasenvironment) and itsselectmatchesprovider.environment === 'production'. - If no strategy applies (or none selects), it falls back to
providers[0].
Because environment is registered first, it takes precedence over
tenant when both could match — registration order is the tie-break.
Building a context-aware provider
createContextAwareProvider is an identity helper over the
ContextAwareProvider interface — { provide(context), canProvide?(context) }
(createContextAwareProvider in nexus/context.ts). It returns its argument unchanged; its only job
is to give you the typed shape. The provide callback receives the
active ResolutionContext and returns the value (or a Promise of it);
the optional canProvide lets a provider opt out of a context:
import { createContextAwareProvider } from '@omnitron-dev/titan/nexus';
const storageProvider = createContextAwareProvider<IStorage>({
provide(context) {
const tenant = context.metadata?.['tenant'];
return tenant?.tier === 'enterprise' ? new S3Storage() : new LocalStorage();
},
canProvide(context) {
return context.metadata?.['tenant'] !== undefined;
},
});
const storage = await storageProvider.provide(resolutionContext);
All the branching lives inside provide; read whatever you need from
context.metadata, which createResolutionContext populated from the
current ContextProvider via toObject() — so a key set as
ContextKeys.Tenant (name 'tenant') is readable at
context.metadata['tenant'].
This is a distinct mechanism from ResolutionStrategy. A
context-aware provider computes one value from the context;
strategies choose between several pre-built providers. Use a
context-aware provider when the value is cheap to compute inline; use
strategies (+ selectProvider) when you have several concrete
implementations to pick from.
Composition with scopes
Contextual providers usually live in Request scope — the context
varies per request, so the resolution should too. A Singleton
contextual provider would resolve once and cache the result,
defeating the purpose.
| Scope | Behaviour with contextual |
|---|---|
Singleton | Strategy runs once on first resolve; result cached forever |
Request | Strategy runs once per request; result cached for the request |
Transient | Strategy runs every resolve |
Request is almost always the right answer. (This assumes you have
wired selectProvider/provide into a factory registered at that
scope — the scope governs how often that factory re-runs, and the
factory is where the strategy or context-aware provide is invoked.)
@InjectContext decorator
@InjectContext(key) is a parameter decorator that marks a
constructor (or method) parameter to receive a single context
value, identified by its ContextKey — it does not inject the
ContextManager:
import { InjectContext, ContextKeys } from '@omnitron-dev/titan/nexus';
@Service({ name: 'users' })
class UsersService {
constructor(
@InjectContext(ContextKeys.Tenant) private readonly tenant: { id: string; name: string },
) {}
@Public()
async whoAmI() {
return this.tenant?.id;
}
}
Under the hood the decorator only records metadata: it writes the key
into a context:inject map keyed by parameter index, via
Reflect.defineMetadata('context:inject', …) (the InjectContext decorator).
The integrating runtime reads that metadata and supplies
getCurrentContext().get(key) for the marked parameter. (Plain
container.resolve() does not interpret context:inject on its own —
it is consumed by the service layer that builds the instance.)
Use it sparingly. Pulling context into business code blurs the line that contextual injection is meant to draw: ideally a service is handed the right implementation and never inspects the context itself.
Custom strategies
Implement ResolutionStrategy — { name, applies(token, ctx), select(providers, ctx) }
— and register it with contextManager.registerStrategy(...):
import type { ResolutionStrategy } from '@omnitron-dev/titan/nexus';
const PaymentTierStrategy: ResolutionStrategy = {
name: 'payment-tier',
applies: (_token, ctx) => ctx.metadata?.['user']?.tier !== undefined,
select: (providers, ctx) => {
const tier = ctx.metadata?.['user']?.tier; // 'free' | 'premium' | …
return providers.find((p) => p.tier === tier) ?? providers[0];
},
};
select receives the candidate provider array and the
ResolutionContext, and returns the chosen provider. applies gates
whether the strategy runs at all for a given token/context.
registerStrategy keys strategies by name, so registering a
strategy whose name matches a built-in one replaces it; use a fresh
name to add alongside the defaults, or unregisterStrategy('environment')
to drop a built-in. Newly registered strategies are appended, so they
are consulted after the four defaults — order matters because the
first strategy that both applies and returns a provider wins
(strategy registration and selectProvider).
Anti-patterns
- Contextual for static decisions. If "which storage" is
decided at boot, use a regular provider (or
EnvironmentStrategyat registration time). Contextual adds runtime cost. - Reading context manually inside services. The point of contextual injection is to push the decision into a strategy or context-aware provider, not into business logic.
- Forgetting to set context. A strategy that reads
tenantfrom a context that was never set has nothing to match on, soselectProviderfalls back toproviders[0]. Make sure middleware that sets context runs (insiderunWithContext) before selection happens. - Strategy with side effects. Strategies are queried during resolution; they should be pure functions of the context.
→ Next: DI Middleware.