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

Illuma implements a sophisticated dependency injection system that operates in distinct phases:

  1. Registration Phase: Providers are registered and converted into proto nodes
  2. Resolution Phase: Proto nodes are resolved into tree nodes with full dependency graphs
  3. 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:

  1. 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.
  2. Deferred (Lazy): Providers are instantiated only when they are first requested via container.get() or nodeInject().

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

  1. Creates a NodeToken<T> with the name _ClassName
  2. Registers the class and token in an internal WeakMap registry
  3. 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

  1. singleton: true is metadata on the token options.
  2. Singleton registration is root-scoped in parent-child container hierarchies.
  3. Instance caching remains TreeNodeSingle-based; root scope changes where the node is attached, not how instances are cached.

Lifecycle integration

  1. Registration phase:
  • Explicit provide() still creates proto nodes as usual.
  • Decorated singleton classes can also be auto-materialized from token metadata when first resolved.
  1. 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.
  1. 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:

  1. instant: true on root:
  • Singleton is instantiated as soon as it is attached to root.
  1. instant: false on 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:

  1. Resolver checks local proto maps.
  2. If missing, resolver checks upstream (parent/root).
  3. If still missing and token is singleton, resolver creates a ProtoNodeSingle from token factory metadata.
  4. That proto node becomes part of the resolved tree and is attached to root container state.
  5. 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

  1. A root singleton can only consume dependencies visible from root.
  2. Child-only providers are not visible to root singleton resolution (including other singleton tokens, but not local overrides in children).
  3. Child explicit overrides remain local to that child container.
  4. Sibling containers continue sharing the same root singleton instance.
  5. Circular dependency checks remain active and unchanged.
  6. 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, through get(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 Injector and LifecycleRef value 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:

  1. Internal bootstrap-only providers are registered
  2. The dependency tree is built and instances are instantiated or pooled
  3. Lifecycle bootstrap hooks run
  4. 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 dependency
  • factory: The function that creates instances of this dependency
  • injections: 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 array
  • multiNodes: 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 node
  • factory: The factory function to create the instance
  • injections: 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 lookup
  • instant: 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 instantiated
  • allocations: Count of how many times this dependency is used (for diagnostics)

Instantiation process:

  1. Check if already resolved (avoid duplicate instantiation)
  2. Recursively instantiate all dependencies first
  3. Create a retriever function that looks up dependencies from the _deps map
  4. Execute the factory within an injection context, providing the retriever
  5. 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-injection
  • instance: The array of instances (public, not cached behind a getter)

Instantiation process:

  1. Check if already resolved
  2. Instantiate all dependencies
  3. Collect instances from each dependency:
    • TreeNodeSingle: Add single instance
    • TreeNodeMulti: Spread array of instances
    • TreeNodeTransparent: Add single instance
  4. 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 active
  • calls: Set of all nodeInject() calls made during the current context
  • injector: 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 injector is null
  • nodeInject() records the call and returns a placeholder iInjectionNode
  • 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 injector is 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 (unless optional: true is 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 resolve
  • cache: Map of already resolved proto nodes (avoids duplicate work)
  • singleNodes: Container's single proto nodes registry
  • multiNodes: Container's multi proto nodes registry
  • upstreamGetter: Function to retrieve dependencies from parent containers

Resolution Steps

  1. Check Cache: If this proto node was already resolved, return the cached tree node

  2. Create Tree Node: Create a tree node corresponding to the proto node type

  3. 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>();
    
  4. Cycle Detection: Track visiting nodes to detect circular dependencies:

    if (visiting.has(proto)) {
      // Extract cycle path and throw InjectionError.circularDependency
    }
    
  5. Dependency Discovery: For each proto node, find its dependencies:

    • For ProtoNodeSingle and ProtoNodeTransparent: Look up tokens from their injections set
    • For ProtoNodeMulti: Collect all single nodes, multi nodes, and transparent nodes
  6. Upstream Resolution: If a dependency isn't found locally, try the parent container:

    const upstream = upstreamGetter?.(token);
    if (upstream) {
      deps.push(upstream);
    }
    
  7. Link Dependencies: Add each dependency to the tree node:

    node.addDependency(dependencyTreeNode);
    
  8. 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 token being instantiated and the original factory function.
  • 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:

  1. 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);
    });
    
  2. 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:

  1. Hierarchical destruction: All child containers are automatically destroyed before their parent containers.
  2. Reverse initialization order: Teardown hooks within a container execute bottom-up in the exact reverse order they were registered.
  3. Immutability: After a container is destroyed, calling destroy() again or attempting to resolve dependencies from it will throw an InjectionError.

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:

  1. TreeRootNode.instantiate() is called

  2. For each dependency in _deps:

    • TreeNodeSingle<Logger>.instantiate(): No dependencies, creates instance
    • TreeNodeSingle<Config>.instantiate(): No dependencies, creates instance
    • TreeNodeSingle<AuthPlugin>.instantiate(): No dependencies, creates instance
    • TreeNodeTransparent<CachePlugin>.instantiate(): No dependencies, creates instance
    • TreeNodeMulti<Plugin>.instantiate(): Instantiates children, collects into array
    • TreeNodeSingle<UserService>.instantiate(): All dependencies ready, creates instance
  3. 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 {}]