Deep dive: How Illuma Works
June 30, 2026 · View on GitHub
This article is an in-depth overview of how dependency injection works in Illuma, covering the internal architecture, data structures, and the lifecycle of dependency resolution and instantiation.
Table of Contents
- Overview
- Key Concepts
- The Container
- Proto Nodes
- Tree Nodes
- Injection Context
- The nodeInject Function
- Dependency Resolution
- Instantiation Process
- Child Containers
- Complete Lifecycle Example
Overview
Illuma implements a sophisticated dependency injection system that operates in distinct phases:
- Registration Phase: Providers are registered and converted into proto nodes
- Resolution Phase: Proto nodes are resolved into tree nodes with full dependency graphs
- Instantiation Phase: Tree nodes are instantiated (either eagerly or lazily when
{ instant: false }is set)
This separation ensures that circular dependencies are detected before any instantiation occurs and allows for efficient batch instantiation of the entire dependency graph.
Key Concepts
Instantiation Modes
Illuma supports two instantiation strategies which can be configured via the instant option:
-
Instant (Eager, default): All providers are instantiated immediately during bootstrap.
- Pros: Fail-fast (instantiation errors detected at startup), predictable runtime performance.
- Cons: Slower startup time for large applications, higher memory usage if some services are never used.
-
Deferred (Lazy): Providers are instantiated only when they are first requested via
container.get()ornodeInject().- Pros: Faster startup time, lower initial memory usage.
- Cons: Instantiation errors might occur at runtime, first request for a service might be slower.
To enable deferred instantiation, pass instant: false to the container options:
const container = new NodeContainer({ instant: false });
Tokens
Tokens are the identifiers used to register and retrieve dependencies:
NodeToken<T>: Represents a single injectable value (singleton pattern)MultiNodeToken<T>: Represents multiple injectable values of the same type (being injected as an array)
The @NodeInjectable Decorator
The @NodeInjectable() decorator is used to mark classes as injectable. It automatically creates and associates a NodeToken with the class, allowing the class to be used directly with the container without manually creating a token.
How it works:
@NodeInjectable()
class UserService {
private readonly _logger = nodeInject(LoggerToken);
public getUser() {
this._logger.log('Fetching user');
return { id: 1, name: 'John' };
}
}
// The class can now be provided and retrieved directly
container.provide(UserService);
container.bootstrap();
const service = container.get(UserService);
What happens internally:
When you apply @NodeInjectable() to a class, the decorator:
- Creates a
NodeToken<T>with the name_ClassName - Registers the class and token in an internal
WeakMapregistry - Associates a factory function
() => new ClassName()with the token
This allows the container to:
- Recognize the class as injectable via
isInjectable() - Extract the associated token via
getInjectableToken() - Use the class constructor as the provider
The use of WeakMap ensures that storing this metadata avoids memory leaks by not preventing garbage collection of the class definitions. Plugins can also utilize this mechanism via registerClassAsInjectable to implement custom decorators.
Alternative for non-decorator environments:
If you're in an environment that doesn't support decorators, use makeInjectable():
class _UserService {
private readonly _logger = nodeInject(LoggerToken);
public getUser() {
this._logger.log('Fetching user');
return { id: 1, name: "John Doe" };
}
}
export type UserService = _UserService;
export const UserService = makeInjectable(_UserService);
Root-scoped singletons ({ singleton: true })
Illuma supports root-scoped singletons for class injectables and NodeToken providers.
You can mark a class as root-scoped singleton with:
@NodeInjectable({ singleton: true })
class AppConfigService {}
or without decorators:
class _AppConfigService {}
const AppConfigService = makeInjectable(_AppConfigService, { singleton: true });
Internal contract
singleton: trueis metadata on the token options.- Singleton registration is root-scoped in parent-child container hierarchies.
- Instance caching remains
TreeNodeSingle-based; root scope changes where the node is attached, not how instances are cached.
Lifecycle integration
- Registration phase:
- Explicit
provide()still creates proto nodes as usual. - Decorated singleton classes can also be auto-materialized from token metadata when first resolved.
- Bootstrap phase:
- Each container builds its local tree from known proto nodes.
- Root singleton nodes discovered later can still be added to root runtime tree state.
- Retrieval phase (
get/nodeInject):
- Local tree lookup is attempted first.
- Parent chain is checked next.
- If token is singleton and unresolved, the token is forwarded to root and attached there.
Instantiation timing and instant
Root singleton instantiation follows the root container strategy:
instant: trueon root:
- Singleton is instantiated as soon as it is attached to root.
instant: falseon root:
- Singleton is attached to root pool first.
- Instantiation happens on first actual access.
This keeps singleton semantics consistent with all other nodes.
Tree resolution behavior
During dependency graph resolution:
- Resolver checks local proto maps.
- If missing, resolver checks upstream (parent/root).
- If still missing and token is singleton, resolver creates a
ProtoNodeSinglefrom token factory metadata. - That proto node becomes part of the resolved tree and is attached to root container state.
- Once the singleton (and any transitive singleton/multi dependencies it pulled in) is attached to the root pool, the transient proto nodes minted during this lazy pass are discarded — exactly as
bootstrap()clears its proto maps. A later, independent resolution therefore reuses the pooled node instead of rediscovering a stale proto and minting a duplicate.
This is why singleton classes can resolve without explicit provide() as long as they were made injectable.
Single-instance guarantee
The root pool maps each token to a single canonical node, and a node never overwrites an entry that already holds a different node for the same token. Combined with the transient-proto cleanup above, this means a singleton resolves to exactly one pooled instance per token, regardless of resolution order or entry path — eager get(), lazy injectDefer, field injection on another @NodeInjectable, or a multi-token alias all converge on the same instance, and a value populated through one path is visible through every other.
(This concerns the single pooled instance handed to consumers; the scan pass described in Factories and constructors run twice still constructs a throwaway instance that is never pooled.)
Visibility and override rules
- A root singleton can only consume dependencies visible from root.
- Child-only providers are not visible to root singleton resolution (including other singleton tokens, but not local overrides in children).
- Child explicit overrides remain local to that child container.
- Sibling containers continue sharing the same root singleton instance.
- Circular dependency checks remain active and unchanged.
- A root singleton aliased into a multi-token (
MULTI.withAlias(SingletonClass)) resolves to the shared root instance: the alias is forwarded upstream rather than materialized as a container-local copy, so the value seen through the multi-token, throughget(SingletonClass), and through field injection is the same instance.
Without @NodeInjectable and makeInjectable:
If you don't use the decorator, you must manually create and use tokens:
// Without decorator - manual token management
const UserServiceToken = new NodeToken<UserService>('UserService');
class UserService {
private readonly logger = nodeInject(LoggerToken);
}
container.provide({ provide: UserServiceToken, useClass: UserService });
container.bootstrap();
const service = container.get(UserServiceToken);
Nodes
Internally, the system uses two lightweight types of node representations:
- Proto Nodes: Metadata nodes created during registration that store information about how to instantiate given tokens
- Tree Nodes: Runtime nodes created during resolution that represent the actual dependency graph and hold instances
Context
InjectionContext is a global state manager that tracks dependency injection calls during factory execution, allowing Illuma to discover dependencies dynamically.
While scanning, factories are being called in complete isolation. It means, nodeInject function does not actually provide a value, but a placeholder proxy object instead, while recording the injection calls for later processing.
The Container
The NodeContainer is the central orchestrator of the dependency injection system. It manages the entire lifecycle from registration to instantiation.
Container Phases
1. Registration Phase (provide)
During registration, the container:
- Accepts various provider formats (classes, tokens, factory functions)
- Creates proto nodes that store metadata about how to create instances
- Validates that no duplicates are registered
- Scans factory functions to discover dependencies
container.provide(UserService);
container.provide({ provide: LoggerToken, factory: () => new ConsoleLogger() });
container.provide({ provide: PluginToken, multi: true, useClass: AuthPlugin });
2. Bootstrap Phase (bootstrap)
During bootstrap, the container:
- Registers the internal
InjectorandLifecycleRefvalue providers for the current container - Converts proto nodes into tree nodes with complete dependency graphs
- Detects circular dependencies
- Instantiates all dependencies in the correct order
- Creates an index of tokens and their corresponding tree nodes (with instances)
- Clears proto node maps to free memory
- Runs
LifecycleRef.afterBootstrap()callbacks in registration order - Emits diagnostics reports only after tree build/instantiation and bootstrap hooks are complete
Observable bootstrap order is strict:
- Internal bootstrap-only providers are registered
- The dependency tree is built and instances are instantiated or pooled
- Lifecycle bootstrap hooks run
- Diagnostics modules receive their reports
container.bootstrap();
3. Retrieval Phase (get)
After bootstrap, instances can be retrieved:
- Looks up tree nodes by token
- Returns the cached instance
- Falls back to parent container if not found (if provided)
const service = container.get(UserServiceToken);
Proto Nodes
Proto nodes are metadata objects created during the registration phase. They store information about how to create instances but don't hold actual instances.
ProtoNodeSingle
Represents a singleton injectable:
class ProtoNodeSingle<T> {
public readonly token: NodeToken<T>;
public readonly injections: Set<iInjectionNode<any>>;
public factory: (() => T) | null = null;
}
token: The unique identifier for this dependencyfactory: The function that creates instances of this dependencyinjections: Discovered dependencies by scanning the factory function
Example:
// When you register:
container.provide({
provide: USER_SERVICE_NODE,
useClass: UserService,
});
// Illuma creates:
new ProtoNodeSingle(
USER_SERVICE_NODE,
() => new UserService()
)
// And scans the factory to discover that LoggerToken is a dependency
ProtoNodeMulti
Represents multiple injectable values of the same type:
class ProtoNodeMulti<T> {
public readonly token: MultiNodeToken<T>;
public readonly singleNodes = new Set<NodeToken<T>>();
public readonly multiNodes = new Set<MultiNodeToken<T>>();
public readonly transparentNodes = new Set<ProtoNodeTransparent<T>>();
}
singleNodes: References to single node tokens that should be included in the arraymultiNodes: References to other multi tokens (for composition)transparentNodes: Direct factory functions without tokens
Example:
const PluginToken = new MultiNodeToken<Plugin>('Plugin');
container.provide({ provide: PluginToken, useClass: AuthPlugin });
container.provide({ provide: PluginToken, useClass: LoggingPlugin });
container.provide({ provide: PluginToken, factory: createCachePlugin });
// Creates a ProtoNodeMulti with:
// - Two single node references (AuthPlugin and LoggingPlugin tokens)
// - One transparent node (the CachePlugin factory)
ProtoNodeTransparent
Transparent nodes are special proto nodes used for factories provided directly to multi tokens without their own token:
class ProtoNodeTransparent<T> {
public readonly factory: () => T;
public readonly injections: Set<iInjectionNode<any>>;
public readonly parent: ProtoNodeSingle<T> | ProtoNodeMulti<T>;
}
parent: Reference to the multi node that owns this transparent nodefactory: The factory function to create the instanceinjections: Dependencies discovered by scanning the factory
Transparent nodes allow you to provide implementations directly without creating dedicated tokens for each one.
Aliases are also being resolved to standard proto nodes with a factory of () => nodeInject(OriginalService).
Tree Nodes
Tree nodes are created during the bootstrap phase and represent the actual runtime dependency graph. Unlike proto nodes, tree nodes hold the actual instantiated values.
TreeRootNode
The root of the dependency tree that manages all top-level dependencies:
class TreeRootNode {
private readonly _deps: Set<TreeNode<any>> = new Set();
private readonly _treePool: InjectionPool = new WeakMap();
constructor(public readonly instant = true) {}
public build(): void;
}
_deps: All top-level dependencies in the container_treePool: Map of tokens to their tree nodes for fast lookupinstant: Whether to instantiate eagerly or defer instantiation
The root node orchestrates the bootstrap process via the build() method. If instant is true, it triggers instantiate() on all dependencies immediately. If false, it collects the dependency pool without instantiating, effectively deferring creation until requested.
TreeNodeSingle
Represents a single instantiated value with its dependencies:
class TreeNodeSingle<T> {
private readonly _transparent: Set<TreeNodeTransparent> = new Set();
private readonly _deps: DependencyPool = new Map();
private _instance: T | null = null;
private _resolved = false;
public allocations = 0;
}
_deps: Map of dependency tokens to their tree nodes_transparent: Set of transparent dependencies (for multi-injection)_instance: The cached instance (null until instantiated)_resolved: Whether this node has been instantiatedallocations: Count of how many times this dependency is used (for diagnostics)
Instantiation process:
- Check if already resolved (avoid duplicate instantiation)
- Recursively instantiate all dependencies first
- Create a retriever function that looks up dependencies from the
_depsmap - Execute the factory within an injection context, providing the retriever
- Cache the result and mark as resolved
TreeNodeTransparent
Similar to TreeNodeSingle but for transparent proto nodes:
class TreeNodeTransparent<T> {
private readonly _transparent = new Set<TreeNodeTransparent>();
private readonly _deps: DependencyPool = new Map();
private _instance: T | null = null;
private _resolved = false;
public allocations = 0;
public readonly proto: ProtoNodeTransparent<T>;
}
Transparent nodes don't have their own token, so they're referenced by their parent multi node. They follow the same instantiation pattern as single nodes.
TreeNodeMulti
Represents multiple values collected into an array:
class TreeNodeMulti<T> {
private readonly _deps = new Set<TreeNode<any>>();
public readonly instance: T[] = [];
private _resolved = false;
public allocations = 0;
}
_deps: All tree nodes that contribute to this multi-injectioninstance: The array of instances (public, not cached behind a getter)
Instantiation process:
- Check if already resolved
- Instantiate all dependencies
- Collect instances from each dependency:
TreeNodeSingle: Add single instanceTreeNodeMulti: Spread array of instancesTreeNodeTransparent: Add single instance
- Mark as resolved
Injection Context
The InjectionContext is a global singleton that manages the state during factory execution. It's crucial for discovering dependencies and providing instances during instantiation.
Context Structure
abstract class InjectionContext {
public static contextOpen = false;
// Internal set of calls
protected static readonly _calls = new Set<iInjectionNode<any>>();
public static injector: InjectorFn | null = null;
}
contextOpen: Whether a context is currently activecalls: Set of allnodeInject()calls made during the current contextinjector: Optional function to provide actual instances (used during instantiation)
Context Lifecycle
1. Scanning Phase (Registration)
When a factory is registered, Illuma scans it to discover dependencies:
const factory = () => new UserService();
// Illuma scans the factory to discover dependencies
const dependencies = InjectionContext.scan(factory);
// Internally, scan():
// 1. Opens a new context
// 2. Executes the factory (expecting it might fail or return proxies)
// 3. Catches any errors
// 4. Returns the collected dependencies
During scanning:
- Context is open but
injectoris null nodeInject()records the call and returns a placeholderiInjectionNode- Factory execution may throw errors (which are caught and ignored)
- The goal is to discover what dependencies are needed, not to construct final instances
Important
Factories and constructors run twice.
Illuma discovers a provider's dependencies by invoking its factory in a
scan context, with every nodeInject(...) call short-circuited to a
placeholder proxy (SHAPE_SHIFTER). That scan runs once
at registration time (provide() for factories, useClass, and
@NodeInjectable() classes), and the factory then runs again for real
during instantiation (bootstrap() or first get() under instant: false).
The proxy makes the scan invocation safe for typical code — property
accesses, method calls, and constructor calls on injected values all return
the same proxy without throwing. But the outer factory body still
executes, which means any side effects in a constructor or factory
(timers, network calls, console.log, throwing on bad config, mutating
module-level state) will fire twice.
Guidance: keep constructors and factories side-effect free. Reserve
side-effecting initialization for an afterBootstrap hook on LifecycleRef,
or perform it lazily on the first method call after construction:
@NodeInjectable()
class DatabaseService {
private readonly _lifecycle = nodeInject(LifecycleRef);
private _connection?: Connection;
constructor() {
// ❌ this runs twice
// this._connection = connectToDb();
// ✅ this runs once, after bootstrap completes
this._lifecycle.afterBootstrap(() => {
this._connection = connectToDb();
});
}
}
Value providers ({ provide: TOKEN, value: ... }) and alias providers are
not affected — their factories are trivial and have no observable effect.
2. Instantiation Phase (Bootstrap)
When instantiating a factory, Illuma provides actual dependencies:
InjectionContext.instantiate(factory, (token, optional) => {
// Look up the dependency in the resolved tree
const node = dependencyPool.get(token);
if (!node && !optional) throw InjectionError.untracked(token);
return node?.instance ?? null;
});
During instantiation:
- Context is open and
injectoris set to a retriever function nodeInject()calls the injector to get actual instances- Factory executes successfully and returns the created instance
- Context is closed after factory completes
iInjectionNode
An iInjectionNode represents a single dependency injection point:
interface iInjectionNode<T> {
readonly token: NodeToken<T> | MultiNodeToken<T>;
readonly optional: boolean;
}
These are collected during scanning to build the dependency graph.
The nodeInject Function
nodeInject() is the core function for declaring dependencies. It must be called within a factory function.
Function Signature
function nodeInject<N>(token: N, options?: iNodeInjectorOptions): ExtractInjectedType<N>;
// where:
interface iNodeInjectorOptions {
optional?: boolean; // return null instead of throwing when not found
self?: boolean; // only resolve from the current container
skipSelf?: boolean; // skip the current container, resolve from a parent
}
Behavior
The function behaves differently depending on the context state:
During Scanning (Context Open, No Injector)
// This is what happens during factory scanning:
const LoggerToken = new NodeToken<Logger>('Logger');
const userServiceFactory = () => {
// During scanning, nodeInject adds an iInjectionNode to InjectionContext
// and returns a proxy object (ShapeShifter) to prevent runtime errors
return new UserService();
};
During Instantiation (Context Open, With Injector)
// This is what happens during actual instantiation:
const userServiceFactory = () => {
// During instantiation, nodeInject calls InjectionContext.injector
// Which returns the actual Logger instance
return new UserService();
};
Outside Context (Error)
// This is invalid and throws an error:
const logger = nodeInject(LoggerToken);
// ↑ InjectionError: nodeInject called outside injection context
Optional Dependencies
Optional dependencies don't throw errors if not found:
container.provide({
provide: ServiceToken,
factory: () => {
const required = nodeInject(RequiredToken);
const optional = nodeInject(OptionalToken, { optional: true });
// optional will be null if OptionalToken is not registered
return new Service();
}
});
Resolution Modifiers (self and skipSelf)
Modifiers can be supplied as the second argument to nodeInject() or container.get() to control how the container resolves dependencies in a hierarchical setup (when using child containers).
self: The container stops traversal and only looks for the provider in the current (local) container. If not found, it throws an error (unlessoptional: trueis set).skipSelf: The container ignores providers in the current container and immediately delegates resolution to the parent container.
@NodeInjectable()
class ConfigLogger {
// Looks exclusively in the local container context
private readonly localConfig = nodeInject(MyToken, { self: true });
}
@NodeInjectable()
class UpstreamLogger {
// Skips the local container completely and looks in the parent
private readonly globalConfig = nodeInject(MyToken, { skipSelf: true });
}
Note: You cannot enforce both self: true and skipSelf: true at the same time, as they are mutually exclusive and will throw a CONFLICTING_STRATEGIES error.
Dependency Resolution
The dependency resolution process transforms proto nodes into tree nodes with complete dependency graphs. This happens during the bootstrap phase.
Resolution Algorithm
The resolveTreeNode() function uses a depth-first traversal with cycle detection:
function resolveTreeNode<T>(
rootProto: ProtoNode<T>,
cache: Map<ProtoNode, TreeNode>,
singleNodes: Map<NodeToken<any>, ProtoNodeSingle>,
multiNodes: Map<MultiNodeToken<any>, ProtoNodeMulti>,
upstreamGetter?: UpstreamGetter
): TreeNode<T>
Parameters:
rootProto: The proto node to resolvecache: Map of already resolved proto nodes (avoids duplicate work)singleNodes: Container's single proto nodes registrymultiNodes: Container's multi proto nodes registryupstreamGetter: Function to retrieve dependencies from parent containers
Resolution Steps
-
Check Cache: If this proto node was already resolved, return the cached tree node
-
Create Tree Node: Create a tree node corresponding to the proto node type
-
Iterative DFS: Use a stack-based approach to resolve all dependencies:
const stack: StackFrame[] = [{ proto: rootProto, node: rootNode, processed: false }]; const visiting = new Set<ProtoNode>(); -
Cycle Detection: Track visiting nodes to detect circular dependencies:
if (visiting.has(proto)) { // Extract cycle path and throw InjectionError.circularDependency } -
Dependency Discovery: For each proto node, find its dependencies:
- For
ProtoNodeSingleandProtoNodeTransparent: Look up tokens from theirinjectionsset - For
ProtoNodeMulti: Collect all single nodes, multi nodes, and transparent nodes
- For
-
Upstream Resolution: If a dependency isn't found locally, try the parent container:
const upstream = upstreamGetter?.(token); if (upstream) { deps.push(upstream); } -
Link Dependencies: Add each dependency to the tree node:
node.addDependency(dependencyTreeNode); -
Cache Result: Store the resolved tree node in the cache for reuse
Example Resolution
// Registration:
container.provide(LoggerToken);
container.provide(CacheToken);
container.provide({
provide: UserServiceToken,
useClass: UserService
});
// During bootstrap, resolution creates:
TreeNodeSingle<UserService> {
_deps: Map {
LoggerToken => TreeNodeSingle<Logger>,
CacheToken => TreeNodeSingle<Cache>
}
}
Instantiation Process
After resolution, the container has a complete dependency graph as tree nodes. The bootstrap process calls build() on the root node which triggers instantiation or pool collection depending on the instant configuration.
Instantiation Order
The TreeRootNode.build() method orchestrates the process:
public build(): void {
for (const dep of this._deps) {
if ("token" in dep.proto) this._treePool.set(dep.proto.token, dep);
// If instant: true, we instantiate immediately
// If instant: false, we just ensure the pool is populated (deferred)
if (this.instant) dep.instantiate(this._treePool);
else dep.collectPool(this._treePool);
}
}
When instantiation happens (either eagerly in build() or lazily upon first request):
Each tree node instantiates its dependencies before instantiating itself, ensuring the correct order.
Single Node Instantiation
class TreeNodeSingle<T> {
public instantiate(pool?: InjectionPool): void {
if (this._resolved) return; // Already instantiated
// 1. Instantiate all dependencies first
for (const node of this._deps.values()) {
node.instantiate(pool);
}
for (const dep of this._transparent) {
dep.instantiate(pool);
}
// 2. Create retriever for nodeInject calls
const retriever = retrieverFactory(this.proto.token, this._deps, this._transparent);
// 3. Execute factory within injection context
const factory = this.proto.factory ?? this.proto.token.opts?.factory;
if (!factory) throw InjectionError.notFound(this.proto.token)
this._resolved = true;
if (pool) pool.set(this.proto.token, this);
}
}
Multi Node Instantiation
class TreeNodeMulti<T> {
public instantiate(pool?: InjectionPool): void {
if (this._resolved) return;
// Instantiate all dependencies and collect instances
for (const dep of this._deps) {
dep.instantiate(pool);
if (dep instanceof TreeNodeSingle) {
this.instance.push(dep.instance);
} else if (dep instanceof TreeNodeMulti) {
this.instance.push(...dep.instance);
} else if (dep instanceof TreeNodeTransparent) {
this.instance.push(dep.instance);
}
}
this._resolved = true;
if (pool) pool.set(this.proto.token, this);
}
}
Retriever Function
The retriever function is critical during instantiation. It's passed as the injector to the injection context:
const retriever = (token: NodeBase<any>, optional?: boolean) => {
// Look up in local dependencies
const depNode = this._deps.get(token);
// Check transparent nodes for multi-injection
if (!depNode && !optional) {
const transparent = Array.from(this._transparent).find(
n => n.proto.parent.token === token
);
if (transparent) return transparent.instance;
throw InjectionError.untracked(token, node);
}
return depNode?.instance ?? null;
};
When nodeInject() is called during factory execution, it calls this retriever to get the actual instance.
Middlewares
Middlewares provide a powerful mechanism to intercept the instantiation process of providers. They can be used for cross-cutting concerns such as logging, profiling, or implementing custom instantiation logic (e.g., proxifying instances).
How It Works
A middleware is a function that wraps the factory execution of a provider. It receives the instantiation parameters and a next function. The middleware can inspect the parameters, execute logic before instantiation, call next to proceed with instantiation, and execute logic after instantiation (on the result).
Middleware Signature
type iMiddleware<T = unknown> = (
params: iInstantiationParams<T>,
next: (params: iInstantiationParams<T>) => T,
) => T;
interface iInstantiationParams<T = unknown> {
readonly token: NodeBase<T>;
readonly factory: () => T;
readonly deps: Set<Token<unknown>>;
}
- params: Contains the
tokenbeing instantiated and the originalfactoryfunction. - next: The function to call to proceed with the chain. It returns the instance of
T.
Middleware Scopes
Middlewares can be registered at two scopes:
-
Container Scope: Applies only to providers instantiated by a specific container.
container.registerMiddleware((params, next) => { console.log(`Instantiating ${params.token.name} with ${params.deps.size} dependencies`); return next(params); }); -
Global Scope: Applies to all providers in all containers.
import { Illuma } from 'illuma'; Illuma.registerGlobalMiddleware((params, next) => { // Global logic return next(params); });
Execution Order
Middlewares are executed in the order they were registered within each scope. Global middlewares run before container-scoped middlewares. If the container has a parent, the parent's middlewares are executed before the child's.
In the current implementation, all middlewares (global and local) are collected and executed in sequence:
global -> grand-parent container -> parent container -> local container
Example: Proxy Middleware
Here is an example of a middleware that wraps every instance in a Proxy:
const proxyMiddleware: iMiddleware = (params, next) => {
const instance = next(params);
if (typeof instance === 'object' && instance !== null) {
return new Proxy(instance, {
get(target, prop) {
console.log(`Accessing ${String(prop)} on ${params.token.name}`);
return Reflect.get(target, prop);
}
});
}
return instance;
};
container.registerMiddleware(proxyMiddleware);
Child Containers
Child containers enable hierarchical dependency injection, where a child container can access dependencies from its parent but not vice versa. You can explicitly adjust how resolution traverses this hierarchy using Resolution Modifiers such as self or skipSelf.
Creating a Child Container
const parent = new NodeContainer();
parent.provide(LoggerToken);
parent.bootstrap();
const child = new NodeContainer({ parent });
child.provide(UserServiceToken); // Can inject LoggerToken from parent
child.bootstrap();
Upstream Resolution
When resolving dependencies, the container uses an upstreamGetter function:
private _getFromParent<T>(token: Token<T>): TreeNode<T> | null {
if (!this._parent) return null;
const parentNode = this._parent as NodeContainer;
return parentNode.findNode(token);
}
This function is passed to resolveTreeNode() and is called when a dependency isn't found locally:
function addDependency(token: Token<any>, optional = false) {
// Try to find in local proto nodes
const localProto = singleNodes.get(token) || multiNodes.get(token);
if (localProto) {
deps.push(localProto);
return;
}
// Try parent container
const upstream = upstreamGetter?.(token);
if (upstream) {
deps.push(upstream); // Use parent's tree node directly
return;
}
// Not found anywhere
if (!optional) throw InjectionError.notFound(token);
}
Instance Retrieval from Parent
When calling get(), if a dependency isn't found locally, the container checks the parent:
public get<T>(token: Token<T>): T | T[] {
const treeNode = this._rootNode.find(token);
if (!treeNode) {
const upstream = this._getFromParent(token);
if (upstream) return upstream.instance;
if (token instanceof MultiNodeToken) return [];
throw InjectionError.notFound(token);
}
return treeNode.instance;
}
Child Container Characteristics
- Isolation: Child containers can override parent dependencies without affecting the parent
- Inheritance: Child containers can access all parent dependencies
- Scoping: Different child containers can have different implementations of the same token
- Lifecycle: Parent must be bootstrapped before child, but they're independent after that
Example use case:
// Parent provides shared services
const parent = new NodeContainer();
parent.provide(DatabaseToken);
parent.provide(ConfigToken);
parent.bootstrap();
// Child 1: Production environment
const prod = new NodeContainer({ parent });
prod.provide({ provide: LoggerToken, useClass: ProductionLogger });
prod.bootstrap();
// Child 2: Development environment
const dev = new NodeContainer({ parent });
dev.provide({ provide: LoggerToken, useClass: DevelopmentLogger });
dev.bootstrap();
// Both children share Database and Config, but have different loggers
Container Destruction and Lifecycles
When a container is no longer needed, you should call container.destroy() to clean up resources, stateful services, and all of its child containers.
When destroy() is called, the following execution rules apply:
- Hierarchical destruction: All child containers are automatically destroyed before their parent containers.
- Reverse initialization order: Teardown hooks within a container execute bottom-up in the exact reverse order they were registered.
- Immutability: After a container is destroyed, calling
destroy()again or attempting to resolve dependencies from it will throw anInjectionError.
Using LifecycleRef
You can use the built-in LifecycleRef token to register destruction hooks from inside a provider or service without directly holding a reference to the container.
import { nodeInject, LifecycleRef } from "@illuma/core";
export class DatabaseService {
private readonly _connection;
private readonly _lifecycle = nodeInject(LifecycleRef);
constructor() {
this._connection = connectToDb();
// Register a hook that executes during container destruction
this._lifecycle.beforeDestroy(() => {
this._connection.close();
});
}
}
The beforeDestroy method returns an unsubscribe function that can be manually called to unregister the hook, which is ideal if your service cleans up its resources early. You can also inspect this._lifecycle.destroyed to check if you are operating on an already-destroyed container from within asynchronous tasks.
Complete Lifecycle Example
Let's walk through a complete example from registration to retrieval:
1. Define Tokens and Classes
const LoggerToken = new NodeToken<Logger>('Logger');
const ConfigToken = new NodeToken<Config>('Config');
const UserServiceToken = new NodeToken<UserService>('UserService');
const PluginToken = new MultiNodeToken<Plugin>('Plugin');
@NodeInjectable()
class Logger { /* ... */ }
@NodeInjectable()
class Config { /* ... */ }
@NodeInjectable()
class UserService {
// Dependencies are injected via nodeInject() in class body
private readonly logger = nodeInject(LoggerToken);
private readonly config = nodeInject(ConfigToken);
private readonly plugins = nodeInject(PluginToken);
}
@NodeInjectable()
class AuthPlugin { /* ... */ }
@NodeInjectable()
class CachePlugin { /* ... */ }
2. Register Providers
const container = new NodeContainer();
// Simple registration
container.provide({
provide: LoggerToken,
useClass: Logger,
});
// With configuration
container.provide({
provide: ConfigToken,
value: { apiUrl: 'https://api.example.com' }
});
// With dependencies
container.provide({
provide: UserServiceToken,
useClass: UserService,
});
// Multi-injection
container.provide({ provide: PluginToken, useClass: AuthPlugin });
container.provide({ provide: PluginToken, factory: () => new CachePlugin() });
Internal state after registration:
container._protoNodes = Map {
LoggerToken => ProtoNodeSingle {
token: LoggerToken,
factory: () => new Logger(),
injections: Set {} // No dependencies
},
ConfigToken => ProtoNodeSingle {
token: ConfigToken,
factory: () => ({ apiUrl: '...' }),
injections: Set {}
},
UserServiceToken => ProtoNodeSingle {
token: UserServiceToken,
factory: () => new UserService(),
injections: Set {
{ token: LoggerToken, optional: false },
{ token: ConfigToken, optional: false },
{ token: PluginToken, optional: false }
}
},
AuthPluginToken => ProtoNodeSingle {
token: AuthPluginToken,
factory: () => new AuthPlugin(),
injections: Set {}
}
}
container._multiProtoNodes = Map {
PluginToken => ProtoNodeMulti {
token: PluginToken,
singleNodes: Set { AuthPluginToken },
transparentNodes: Set {
ProtoNodeTransparent {
parent: PluginToken,
factory: () => new CachePlugin(),
injections: Set {}
}
}
}
}
3. Bootstrap
container.bootstrap();
Resolution phase creates tree nodes:
// TreeRootNode is created with all dependencies
container._rootNode = TreeRootNode {
_deps: Set {
TreeNodeSingle<Logger> {
proto: ProtoNodeSingle<Logger>,
_deps: Map {}, // No dependencies
_instance: null
},
TreeNodeSingle<Config> {
proto: ProtoNodeSingle<Config>,
_deps: Map {},
_instance: null
},
TreeNodeSingle<AuthPlugin> {
proto: ProtoNodeSingle<AuthPlugin>,
_deps: Map {},
_instance: null
},
TreeNodeTransparent<CachePlugin> {
proto: ProtoNodeTransparent<CachePlugin>,
_deps: Map {},
_instance: null
},
TreeNodeMulti<Plugin> {
proto: ProtoNodeMulti<Plugin>,
_deps: Set {
TreeNodeSingle<AuthPlugin>,
TreeNodeTransparent<CachePlugin>
},
instance: []
},
TreeNodeSingle<UserService> {
proto: ProtoNodeSingle<UserService>,
_deps: Map {
LoggerToken => TreeNodeSingle<Logger>,
ConfigToken => TreeNodeSingle<Config>,
PluginToken => TreeNodeMulti<Plugin>
},
_instance: null
}
},
_treePool: Map {} // Empty until instantiation
}
Instantiation phase:
-
TreeRootNode.instantiate()is called -
For each dependency in
_deps:TreeNodeSingle<Logger>.instantiate(): No dependencies, creates instanceTreeNodeSingle<Config>.instantiate(): No dependencies, creates instanceTreeNodeSingle<AuthPlugin>.instantiate(): No dependencies, creates instanceTreeNodeTransparent<CachePlugin>.instantiate(): No dependencies, creates instanceTreeNodeMulti<Plugin>.instantiate(): Instantiates children, collects into arrayTreeNodeSingle<UserService>.instantiate(): All dependencies ready, creates instance
-
After instantiation:
container._rootNode = TreeRootNode {
_deps: Set { /* same tree nodes */ },
_treePool: Map {
LoggerToken => TreeNodeSingle { _instance: Logger {}, _resolved: true },
ConfigToken => TreeNodeSingle { _instance: { apiUrl: '...' }, _resolved: true },
AuthPluginToken => TreeNodeSingle { _instance: AuthPlugin {}, _resolved: true },
PluginToken => TreeNodeMulti { instance: [AuthPlugin {}, CachePlugin {}], _resolved: true },
UserServiceToken => TreeNodeSingle { _instance: UserService {}, _resolved: true }
}
}
4. Retrieve Instances
const logger = container.get(LoggerToken);
// Returns the Logger instance from _treePool
const userService = container.get(UserServiceToken);
// Returns the UserService instance with all dependencies injected
const plugins = container.get(PluginToken);
// Returns [AuthPlugin {}, CachePlugin {}]