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:
- Checks its own providers: It looks for a provider for
SomeTokenamong the providers it was created with. - 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 itsdepsrecursively) and returns the result. - If the provider is
useExisting, it resolves the aliased token.
- If the provider is
- If not found: It delegates the request to its parent injector.
- If no parent or no provider found anywhere: It throws an error, indicating that a provider for
SomeTokencould 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: ProvidesConsoleWriterandLogger. Any component resolved byrootInjector(or its descendants that don’t overrideLogger) will receive the same instance ofLogger.requestInjector:- It provides
REQUEST_ID(specific to each request) andRequestContext. - When
RequestContextis created, it asks forREQUEST_ID(found inrequestInjector) andLogger. - Since
Loggeris not provided directly inrequestSpecificProviders,requestInjectorasks its parent (rootInjector) forLogger. TherootInjectorprovides its singleton instance. - This demonstrates how services can be scoped.
RequestContextis unique per request, butLoggeris shared across all requests.
- It provides
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 telladminRequestInjectorthat when someone asks forLogger, they should get theAdminLoggerinstance (useExisting). - The
RequestContextwithin theadminRequestInjectorresolvesLoggertoAdminLogger, even though the parent (rootInjector) providesLoggerasStandardLogger. - Crucially, the
rootInjectoritself and any other child injectors not overridingLoggerstill receive the originalStandardLoggerinstance. 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, orRequestContextcan 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.
Re-use
APP_CONFIGandAppConfiginterface fromsrc/tokens.ts(Chapter 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})`; } }Modify
app.ts:- Create a
rootInjectorthat providesLoggerand any shared application-level services. - Define two different
AppConfigobjects:usersModuleConfigandproductsModuleConfig. - Create two child injectors,
usersModuleInjectorandproductsModuleInjector, each with its ownAPP_CONFIGandModuleService. - Resolve
ModuleServicefrom each child injector and callperformModuleAction().
// 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); // trueExpected Output Insight: Each
ModuleServiceinstance should report a different API URL based on its specificAPP_CONFIG, while all logging operations go through the sameLoggerinstance provided by the root.- Create a
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.
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; } }Modify
app.ts:- Provide
CounterServiceat the root level. - Create a child injector (e.g.,
sessionInjector) and provideCounterServiceagain at this child level. - Create another child injector (
anotherSessionInjector) from the root, also providingCounterServiceat its level. - Create a “nested” child injector (
nestedSessionInjector) fromsessionInjector, not providingCounterService. - Resolve
CounterServicefrom various injectors and callincrement()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); // falseExpected Output Insight: You should observe three distinct
CounterServiceinstances (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.- Provide
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.