Working with Injectors and Hierarchies in Injection-JS

3. Working with Injectors and Hierarchies

In the previous chapter, we learned about services, providers, and how to create a basic injector. Now, let’s dive deeper into how ReflectiveInjector resolves dependencies and, more importantly, how to build sophisticated injector hierarchies to manage dependencies in large, modular applications.

The Role of ReflectiveInjector

The ReflectiveInjector is the central component that injection-js uses to resolve dependencies. When you call injector.get(SomeToken), it performs the following steps:

  1. Checks its own providers: It looks for a provider for SomeToken among the providers it was created with.
  2. If found:
    • If the provider is useValue, it returns the value.
    • If the provider is useClass, it instantiates the class (and resolves its own constructor dependencies recursively).
    • If the provider is useFactory, it executes the factory function (resolving its deps recursively) and returns the result.
    • If the provider is useExisting, it resolves the aliased token.
  3. If not found: It delegates the request to its parent injector.
  4. If no parent or no provider found anywhere: It throws an error, indicating that a provider for SomeToken could not be found.

This delegation mechanism is what makes injector hierarchies so powerful.

Understanding Injector Hierarchies

An injector hierarchy is a tree-like structure of injectors, where each injector can have a parent. This structure allows you to:

  • Provide different instances of a service at different levels: A service provided at a “root” level will be a singleton across the entire application. A service provided at a “child” level will be a singleton only within that child’s scope and its descendants.
  • Scope services to specific parts of your application: This is crucial for resource management (e.g., database connections per request, user-specific data) and isolating concerns.
  • Override services: Child injectors can provide a different implementation of a service that’s already provided by a parent, effectively overriding the parent’s definition for that specific branch of the hierarchy.

Creating a Child Injector

You create a child injector by calling the resolveAndCreate method on an existing injector, passing the parent injector as the second argument:

ReflectiveInjector.resolveAndCreate(childProviders, parentInjector);

Let’s illustrate with an example.

// src/app.ts (reusing components from Chapter 2)
import "reflect-metadata";
import { ReflectiveInjector, Injectable, Inject, InjectionToken } from 'injection-js';

// --- Services & Tokens (simplified for brevity, assume they are @Injectable and defined) ---
// See Chapter 2 files: src/services/logger.ts, src/services/console-writer.ts, src/tokens.ts

// Simplified for demonstration in this single file, in real app keep them separate.
// We are focusing on injector hierarchy.

// Basic Logger and ConsoleWriter
@Injectable()
export class ConsoleWriter {
  write(message: string): void { console.log(`CONSOLE: ${message}`); }
  writeError(message: string): void { console.error(`ERROR: ${message}`); }
}

@Injectable()
export class Logger {
  constructor(private writer: ConsoleWriter) {}
  log(message: string): void { this.writer.write(`LOG: ${message}`); }
  error(message: string): void { this.writer.writeError(`ERROR: ${message}`); }
}

// A token for a unique request ID
export const REQUEST_ID = new InjectionToken<string>('REQUEST_ID');

@Injectable()
export class RequestContext {
  constructor(@Inject(REQUEST_ID) public id: string, private logger: Logger) {
    this.logger.log(`RequestContext created with ID: ${id}`);
  }

  getInfo(): string {
    return `Request handled by context ID: ${this.id}`;
  }
}

// --- Setting up the hierarchy ---

// 1. Root Injector (Application-wide singletons)
const rootProviders = [
  ConsoleWriter,
  Logger // Logger provided at the root will be a singleton across all children if not overridden
];
const rootInjector = ReflectiveInjector.resolveAndCreate(rootProviders);

const appLogger = rootInjector.get(Logger);
appLogger.log("Application started.");

// 2. Creating a child injector for a specific "request"
function handleRequest(requestId: string): void {
  console.log(`\n--- Handling Request: ${requestId} ---`);

  const requestSpecificProviders = [
    { provide: REQUEST_ID, useValue: requestId }, // Request-specific ID
    RequestContext // RequestContext depends on REQUEST_ID and Logger
  ];

  // Create a child injector, inheriting from the rootInjector
  const requestInjector = ReflectiveInjector.resolveAndCreate(
    requestSpecificProviders,
    rootInjector // This makes rootInjector the parent
  );

  // Get RequestContext and Logger from the requestInjector
  const requestContext = requestInjector.get(RequestContext);
  const requestLogger = requestInjector.get(Logger); // This will resolve the Logger from the rootInjector (parent)

  requestLogger.log(requestContext.getInfo()); // Logger logs via the parent's Logger instance

  // If we try to get a service not provided by child or parent, it will throw
  // try {
  //   requestInjector.get(NonExistentService);
  // } catch (e: any) {
  //   console.error("Error getting NonExistentService:", e.message);
  // }

  console.log(`--- Request ${requestId} Finished ---`);
}

// Simulate multiple requests
handleRequest("REQ-001");
handleRequest("REQ-002");

// Verify that the root logger is indeed a singleton
const anotherAppLogger = rootInjector.get(Logger);
console.log("\nAre appLogger and anotherAppLogger the same instance?",
            appLogger === anotherAppLogger); // Should be true

Explanation:

  • rootInjector: Provides ConsoleWriter and Logger. Any component resolved by rootInjector (or its descendants that don’t override Logger) will receive the same instance of Logger.
  • requestInjector:
    • It provides REQUEST_ID (specific to each request) and RequestContext.
    • When RequestContext is created, it asks for REQUEST_ID (found in requestInjector) and Logger.
    • Since Logger is not provided directly in requestSpecificProviders, requestInjector asks its parent (rootInjector) for Logger. The rootInjector provides its singleton instance.
    • This demonstrates how services can be scoped. RequestContext is unique per request, but Logger is shared across all requests.

Overriding Parent Providers

A child injector can define a provider for a token that is already provided by its parent. In this case, the child’s provider takes precedence within its scope and for any of its own descendants. The parent’s provider is not affected and continues to provide its instance to other siblings or direct parent consumers.

Let’s modify the example to override the Logger in a child injector:

// src/app-overriding.ts (new file for this example)
import "reflect-metadata";
import { ReflectiveInjector, Injectable, InjectionToken } from 'injection-js';

// Re-using simplified ConsoleWriter
@Injectable()
export class ConsoleWriter {
  write(message: string): void { console.log(`CONSOLE: ${message}`); }
  writeError(message: string): void { console.error(`ERROR: ${message}`); }
}

// Standard Logger
@Injectable()
export class Logger {
  constructor(private writer: ConsoleWriter) {}
  log(message: string): void { this.writer.write(`[Standard Logger] ${message}`); }
}

// A special logger for administrative tasks
@Injectable()
export class AdminLogger {
  constructor(private writer: ConsoleWriter) {}
  log(message: string): void { this.writer.write(`[ADMIN LOGGER] --- ${message} ---`); }
}

// RequestContext (depends on Logger)
export const REQUEST_ID = new InjectionToken<string>('REQUEST_ID');

@Injectable()
export class RequestContext {
  constructor(@Inject(REQUEST_ID) public id: string, private logger: Logger) {
    this.logger.log(`RequestContext created with ID: ${id}`);
  }

  getInfo(): string {
    return `Request handled by context ID: ${this.id}`;
  }
}

// --- Setting up the hierarchy with override ---

// Root Injector
const rootProviders = [
  ConsoleWriter,
  Logger // Provides the Standard Logger
];
const rootInjector = ReflectiveInjector.resolveAndCreate(rootProviders);

const appLogger = rootInjector.get(Logger);
appLogger.log("Application root initialized."); // Uses Standard Logger

// Request 1: Uses the standard logger from the root
function handleNormalRequest(requestId: string): void {
  console.log(`\n--- Handling Normal Request: ${requestId} ---`);

  const requestProviders = [
    { provide: REQUEST_ID, useValue: requestId },
    RequestContext
  ];

  const requestInjector = ReflectiveInjector.resolveAndCreate(
    requestProviders,
    rootInjector
  );

  const requestContext = requestInjector.get(RequestContext);
  requestContext.getInfo();
}

// Request 2: Overrides Logger with AdminLogger
function handleAdminRequest(requestId: string): void {
  console.log(`\n--- Handling Admin Request: ${requestId} ---`);

  const adminRequestProviders = [
    { provide: REQUEST_ID, useValue: requestId },
    AdminLogger, // Make AdminLogger available
    { provide: Logger, useExisting: AdminLogger }, // Override Logger token to point to AdminLogger
    RequestContext // RequestContext still asks for Logger, but now gets AdminLogger
  ];

  const adminRequestInjector = ReflectiveInjector.resolveAndCreate(
    adminRequestProviders,
    rootInjector
  );

  const adminRequestContext = adminRequestInjector.get(RequestContext);
  adminRequestContext.getInfo();

  // The logger obtained from THIS injector is AdminLogger
  const adminLoggerInstance = adminRequestInjector.get(Logger);
  adminLoggerInstance.log("Admin specific action performed.");
}

handleNormalRequest("WEB-001");
handleAdminRequest("ADMIN-001");
handleNormalRequest("WEB-002"); // Should still use the Standard Logger from root

// Verify that the root logger instance is still the Standard Logger
const rootLoggerAfterRequests = rootInjector.get(Logger);
console.log("\nRoot logger instance is still the Standard Logger?",
            rootLoggerAfterRequests === appLogger); // Should be true
rootLoggerAfterRequests.log("Root continues to use its original Logger.");

Key Takeaways from the Override Example:

  • In handleAdminRequest, we explicitly tell adminRequestInjector that when someone asks for Logger, they should get the AdminLogger instance (useExisting).
  • The RequestContext within the adminRequestInjector resolves Logger to AdminLogger, even though the parent (rootInjector) provides Logger as StandardLogger.
  • Crucially, the rootInjector itself and any other child injectors not overriding Logger still receive the original StandardLogger instance. This illustrates powerful isolation.

Use Cases for Injector Hierarchies

  • Request-Scoped Services (e.g., in Node.js HTTP servers): Create a child injector for each incoming HTTP request. Services like CurrentUser, TransactionManager, or RequestContext can be provided within this child injector, ensuring each request gets its own isolated instance.
  • Module-Scoped Services: In a large application, you might have feature modules (e.g., UserModule, ProductModule). Each module can have its own child injector to manage services specific to that module, preventing naming conflicts and allowing module-specific configurations.
  • Testing: Child injectors are excellent for integration and component testing. You can create an injector for a component and provide mock or test-specific dependencies within that injector, isolating the component under test from the rest of the application.
  • Dynamic Feature Loading: If parts of your application are loaded dynamically, you can create a child injector for that dynamic segment, providing its specific services.

Exercises / Mini-Challenges

Exercise 3.1: Scoped Configuration per Module

Objective: Create a scenario where different “modules” of an application have their own configuration values, demonstrating isolated configuration using child injectors.

  1. Re-use APP_CONFIG and AppConfig interface from src/tokens.ts (Chapter 2).

  2. Create a ModuleService:

    // src/services/module.service.ts
    import { Injectable, Inject } from 'injection-js';
    import { APP_CONFIG, AppConfig } from '../tokens';
    import { Logger } from './logger'; // Assuming Logger is available
    
    @Injectable()
    export class ModuleService {
      constructor(
        @Inject(APP_CONFIG) private config: AppConfig,
        private logger: Logger
      ) {
        this.logger.log(`ModuleService initialized. API URL: ${this.config.apiUrl}`);
      }
    
      performModuleAction(): string {
        return `Action performed using API: ${this.config.apiUrl} (Env: ${this.config.environment})`;
      }
    }
    
  3. Modify app.ts:

    • Create a rootInjector that provides Logger and any shared application-level services.
    • Define two different AppConfig objects: usersModuleConfig and productsModuleConfig.
    • Create two child injectors, usersModuleInjector and productsModuleInjector, each with its own APP_CONFIG and ModuleService.
    • Resolve ModuleService from each child injector and call performModuleAction().
    // src/app.ts (or new file like module-hierarchy.ts)
    import "reflect-metadata";
    import { ReflectiveInjector, Injectable, Inject, InjectionToken } from 'injection-js';
    
    // Simplified for this file, assuming actual files for ConsoleWriter, Logger, AppConfig, APP_CONFIG
    // (You should ideally import them from their respective files as done in previous exercises)
    @Injectable()
    export class ConsoleWriter { /* ... */ }
    @Injectable()
    export class Logger { /* ... */ } // Needs ConsoleWriter
    export const APP_CONFIG = new InjectionToken<any>('APP_CONFIG');
    export interface AppConfig { apiUrl: string; environment: 'development' | 'production'; }
    
    @Injectable()
    export class ModuleService {
      constructor(
        @Inject(APP_CONFIG) private config: AppConfig,
        private logger: Logger
      ) {
        this.logger.log(`ModuleService initialized. API URL: ${this.config.apiUrl}`);
      }
      performModuleAction(): string {
        return `Action performed using API: ${this.config.apiUrl} (Env: ${this.config.environment})`;
      }
    }
    
    // Root providers
    const rootProviders = [
      ConsoleWriter,
      Logger
    ];
    const rootInjector = ReflectiveInjector.resolveAndCreate(rootProviders);
    rootInjector.get(Logger).log("Application root initialized.");
    
    // Users Module Configuration
    const usersModuleConfig: AppConfig = {
      apiUrl: 'https://api.myapp.com/users',
      environment: 'development'
    };
    const usersModuleProviders = [
      { provide: APP_CONFIG, useValue: usersModuleConfig },
      ModuleService
    ];
    const usersModuleInjector = ReflectiveInjector.resolveAndCreate(
      usersModuleProviders,
      rootInjector // Inherit Logger from root
    );
    
    // Products Module Configuration
    const productsModuleConfig: AppConfig = {
      apiUrl: 'https://api.myapp.com/products',
      environment: 'production'
    };
    const productsModuleProviders = [
      { provide: APP_CONFIG, useValue: productsModuleConfig },
      ModuleService
    ];
    const productsModuleInjector = ReflectiveInjector.resolveAndCreate(
      productsModuleProviders,
      rootInjector // Inherit Logger from root
    );
    
    // Using the services from different module injectors
    const usersModuleService = usersModuleInjector.get(ModuleService);
    console.log(usersModuleService.performModuleAction());
    
    const productsModuleService = productsModuleInjector.get(ModuleService);
    console.log(productsModuleService.performModuleAction());
    
    // Verify logger instances (should be the same from root)
    const loggerFromUsersModule = usersModuleInjector.get(Logger);
    const loggerFromProductsModule = productsModuleInjector.get(Logger);
    const loggerFromRoot = rootInjector.get(Logger);
    
    console.log("\nLogger from Users Module is same as Root Logger?", loggerFromUsersModule === loggerFromRoot); // true
    console.log("Logger from Products Module is same as Root Logger?", loggerFromProductsModule === loggerFromRoot); // true
    

    Expected Output Insight: Each ModuleService instance should report a different API URL based on its specific APP_CONFIG, while all logging operations go through the same Logger instance provided by the root.

Exercise 3.2: Transient vs. Singleton Services in Hierarchy

Objective: Understand the difference between services provided at different levels of the hierarchy regarding their instance lifecycle.

  1. Create a CounterService:

    // src/services/counter.service.ts
    import { Injectable } from 'injection-js';
    
    @Injectable()
    export class CounterService {
      private count: number = 0;
    
      increment(): number {
        this.count++;
        return this.count;
      }
    
      getCount(): number {
        return this.count;
      }
    }
    
  2. Modify app.ts:

    • Provide CounterService at the root level.
    • Create a child injector (e.g., sessionInjector) and provide CounterService again at this child level.
    • Create another child injector (anotherSessionInjector) from the root, also providing CounterService at its level.
    • Create a “nested” child injector (nestedSessionInjector) from sessionInjector, not providing CounterService.
    • Resolve CounterService from various injectors and call increment() to observe how instances differ or are shared.
    // src/app.ts (or new file like lifecycle-hierarchy.ts)
    import "reflect-metadata";
    import { ReflectiveInjector, Injectable } from 'injection-js';
    
    @Injectable()
    export class CounterService {
      private count: number = 0;
      private instanceId: string; // To help differentiate instances
    
      constructor() {
        this.instanceId = Math.random().toString(36).substring(7);
        console.log(`CounterService instance created: ${this.instanceId}`);
      }
    
      increment(): number {
        this.count++;
        return this.count;
      }
    
      getCount(): number {
        return this.count;
      }
    
      getInstanceId(): string {
        return this.instanceId;
      }
    }
    
    // Root Injector
    const rootProviders = [
      CounterService // Root-level singleton
    ];
    const rootInjector = ReflectiveInjector.resolveAndCreate(rootProviders);
    
    const rootCounter = rootInjector.get(CounterService);
    console.log(`Root Counter: ${rootCounter.getInstanceId()} -> ${rootCounter.increment()}`); // 1
    console.log(`Root Counter: ${rootCounter.getInstanceId()} -> ${rootCounter.increment()}`); // 2
    
    // Child Injector 1 (Session 1) - Overrides CounterService
    const session1Providers = [
      CounterService // New instance within this scope
    ];
    const session1Injector = ReflectiveInjector.resolveAndCreate(session1Providers, rootInjector);
    const session1Counter = session1Injector.get(CounterService);
    console.log(`\nSession 1 Counter: ${session1Counter.getInstanceId()} -> ${session1Counter.increment()}`); // 1
    console.log(`Session 1 Counter: ${session1Counter.getInstanceId()} -> ${session1Counter.increment()}`); // 2
    
    // Nested Child Injector (from Session 1) - DOES NOT override CounterService
    const nestedSessionProviders: any[] = []; // No CounterService provided here
    const nestedSessionInjector = ReflectiveInjector.resolveAndCreate(nestedSessionProviders, session1Injector);
    const nestedCounter = nestedSessionInjector.get(CounterService); // Should get from session1Injector
    console.log(`\nNested Session Counter (from Session 1 parent): ${nestedCounter.getInstanceId()} -> ${nestedCounter.increment()}`); // 3
    console.log("Nested Counter is same instance as Session 1 Counter?", nestedCounter === session1Counter); // true
    
    // Child Injector 2 (Session 2) - Overrides CounterService (sibling to Session 1)
    const session2Providers = [
      CounterService // Yet another new instance for this scope
    ];
    const session2Injector = ReflectiveInjector.resolveAndCreate(session2Providers, rootInjector);
    const session2Counter = session2Injector.get(CounterService);
    console.log(`\nSession 2 Counter: ${session2Counter.getInstanceId()} -> ${session2Counter.increment()}`); // 1
    
    console.log(`\nFinal Root Counter value: ${rootCounter.getCount()}`); // 2 (unchanged by children)
    console.log(`Final Session 1 Counter value: ${session1Counter.getCount()}`); // 3
    console.log(`Final Session 2 Counter value: ${session2Counter.getCount()}`); // 1
    
    // Confirm instances are different
    console.log("Root Counter === Session 1 Counter?", rootCounter === session1Counter); // false
    console.log("Root Counter === Session 2 Counter?", rootCounter === session2Counter); // false
    console.log("Session 1 Counter === Session 2 Counter?", session1Counter === session2Counter); // false
    

    Expected Output Insight: You should observe three distinct CounterService instances (one for root, one for Session 1, one for Session 2), each incrementing independently within its own scope. The nested injector from Session 1 will share the same instance as its direct parent, Session 1. This highlights how DI hierarchies control service lifecycles.

By completing these exercises, you’ll gain a solid grasp of how ReflectiveInjector works with hierarchies and how to effectively manage the scope and lifecycle of your services.