Core Concepts of Dependency Injection with Injection-JS

2. Core Concepts of Dependency Injection

Now that your environment is set up, let’s dive into the fundamental concepts that power Dependency Injection with Injection-JS. Understanding these building blocks is crucial for effectively structuring your applications.

The primary goal of DI is to provide instances of dependencies to a class rather than having the class create them itself. This chapter will introduce you to the key players in this process: Services, Providers, Injection Tokens, and the @Injectable decorator.

Services (Dependencies)

In DI, a “service” is typically a class or function that performs a specific task and can be injected into other parts of your application. These are the “dependencies” that other classes will rely on. Services encapsulate business logic, data access, logging, or any other functionality that should be reusable and independent.

Let’s define a simple Logger service.

// src/services/logger.ts
import "reflect-metadata"; // Ensure this is at the top of your main entry file or shared module

// We'll add @Injectable later, for now, it's just a class
export class Logger {
  log(message: string): void {
    const timestamp = new Date().toISOString();
    console.log(`[${timestamp}] LOG: ${message}`);
  }

  error(message: string): void {
    const timestamp = new Date().toISOString();
    console.error(`[${timestamp}] ERROR: ${message}`);
  }
}

// src/app.ts (for demonstration, you would usually import Logger here)
// import { Logger } from './services/logger';
// const logger = new Logger(); // Manually creating
// logger.log("Application started without DI.");

In the traditional way (without DI), if another class, say UserService, needed logging, it would create an instance of Logger itself: this.logger = new Logger();. This creates tight coupling. With DI, the UserService would declare its need for a Logger, and the DI system would provide it.

The @Injectable() Decorator

The @Injectable() decorator from injection-js marks a class as eligible for injection. When you apply @Injectable() to a class, it tells the DI system that this class might have dependencies that need to be injected, or that this class itself can be injected as a dependency.

Specifically, when emitDecoratorMetadata is enabled in tsconfig.json and reflect-metadata is imported, @Injectable() allows injection-js to infer the types of constructor parameters for automatic dependency resolution.

Let’s modify our Logger class:

// src/services/logger.ts
import "reflect-metadata"; // Important for decorators
import { Injectable } from "injection-js";

@Injectable()
export class Logger {
  log(message: string): void {
    const timestamp = new Date().toISOString();
    console.log(`[${timestamp}] LOG: ${message}`);
  }

  error(message: string): void {
    const timestamp = new Date().toISOString();
    console.error(`[${timestamp}] ERROR: ${message}`);
  }
}

Now, Logger is ready to be managed by the Injection-JS container.

Providers

Providers are the heart of the Dependency Injection system. They tell the injector how to get an instance of a dependency when it’s requested. A provider maps a token (what you ask for) to an implementation (what you get).

Injection-JS supports several types of providers:

  1. Class Providers (useClass): This is the most common type. When a dependency is requested by its class type, the injector instantiates that class.

    // src/services/data.service.ts
    import { Injectable } from "injection-js";
    import { Logger } from "./logger"; // Assuming Logger is @Injectable()
    
    @Injectable()
    export class DataService {
      constructor(private logger: Logger) {
        // The logger instance is injected here by the DI system
      }
    
      getSomeData(): string {
        this.logger.log("Fetching some data...");
        return "Here's your data!";
      }
    }
    

    To make DataService available, you would provide it: ReflectiveInjector.resolveAndCreate([Logger, DataService]). When DataService is requested, the injector will see its constructor needs a Logger and will provide an instance of Logger.

  2. Value Providers (useValue): Use this to provide a specific, pre-existing value (object, string, number, function) as a dependency. This is useful for configuration objects, global constants, or mock objects in tests.

    // src/tokens.ts
    import { InjectionToken } from 'injection-js';
    
    export const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG');
    
    export interface AppConfig {
      apiUrl: string;
      environment: 'development' | 'production';
    }
    
    // src/app.ts (or a module where you define providers)
    import { ReflectiveInjector } from 'injection-js';
    import { APP_CONFIG } from './tokens';
    
    const myAppConfig: AppConfig = {
      apiUrl: 'https://api.example.com/v1',
      environment: 'development'
    };
    
    const injector = ReflectiveInjector.resolveAndCreate([
      { provide: APP_CONFIG, useValue: myAppConfig }
    ]);
    
    // Later, a service can inject APP_CONFIG
    // @Injectable()
    // class SomeService {
    //   constructor(@Inject(APP_CONFIG) private config: AppConfig) {
    //     console.log(this.config.apiUrl);
    //   }
    // }
    

    Notice the use of APP_CONFIG as an InjectionToken. More on that next.

  3. Factory Providers (useFactory): This allows you to provide a dependency by calling a factory function. The factory function can itself have dependencies, which are passed to it by the injector. This is powerful for creating dependencies conditionally or with complex setup logic.

    // src/services/analytics.service.ts
    import { Injectable, InjectionToken, Inject } from 'injection-js';
    import { APP_CONFIG, AppConfig } from '../tokens'; // Reusing AppConfig
    
    export class AnalyticsService {
      constructor(private trackerId: string) {}
    
      trackEvent(eventName: string, data: any): void {
        console.log(`[Analytics-${this.trackerId}] Tracking '${eventName}' with data:`, data);
      }
    }
    
    // Factory function for AnalyticsService
    export function analyticsServiceFactory(config: AppConfig): AnalyticsService {
      const trackerId = config.environment === 'production' ? 'PROD_GA_123' : 'DEV_GA_ABC';
      return new AnalyticsService(trackerId);
    }
    
    // A provider using useFactory
    // {
    //   provide: AnalyticsService,
    //   useFactory: analyticsServiceFactory,
    //   deps: [APP_CONFIG] // Declare dependencies for the factory function
    // }
    

    The deps array is crucial here. It tells the injector which dependencies to fetch and pass as arguments to the analyticsServiceFactory.

  4. Alias Providers (useExisting): This allows one token to be an alias for another. When the alias token is requested, the injector provides the instance associated with the aliased token. Useful for providing a common interface under multiple names.

    // src/services/fancy-logger.ts
    import { Injectable } from 'injection-js';
    import { Logger } from './logger';
    
    @Injectable()
    export class FancyLogger extends Logger {
      override log(message: string): void {
        console.log(`✨✨ [FANCY LOG] ${message} ✨✨`);
      }
    }
    
    // You might want to provide FancyLogger as the default Logger implementation:
    // { provide: Logger, useClass: FancyLogger } // This is also valid
    // Or you can alias it:
    // { provide: FancyLogger, useClass: FancyLogger },
    // { provide: Logger, useExisting: FancyLogger }
    

    In this alias example, if someone asks for Logger, they will get the FancyLogger instance. Note that FancyLogger itself must also be provided (e.g., via useClass) for useExisting to work.

Injection Tokens (InjectionToken)

When you provide a class, its class type acts as its injection token (e.g., Logger). However, for non-class dependencies (like configuration objects, primitive values, or interfaces), you need a unique identifier. This is where InjectionToken comes in.

InjectionToken provides a unique and unambiguous identifier for a dependency. It’s especially useful for:

  • Providing primitive values (strings, numbers, booleans).
  • Providing plain JavaScript objects.
  • Providing functions.
  • Defining dependencies for abstract classes or interfaces (though TypeScript interfaces are erased at runtime, so a token is needed to refer to them).
// src/tokens.ts (revisited)
import { InjectionToken } from 'injection-js';

// A token for a simple string message
export const GREETING_MESSAGE = new InjectionToken<string>('GREETING_MESSAGE');

// A token for a configuration object
export interface AppConfig {
  apiUrl: string;
  environment: 'development' | 'production';
}
export const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG');

The string argument to InjectionToken (e.g., 'GREETING_MESSAGE') is a descriptive name, primarily for debugging purposes. It does not need to be unique across your application; the InjectionToken instance itself is the unique identifier.

The @Inject() Decorator

While injection-js can often infer dependencies for @Injectable() classes based on TypeScript’s type metadata, sometimes you need to explicitly tell the injector what to provide. This is especially true when:

  • Injecting a dependency identified by an InjectionToken (like APP_CONFIG).
  • Injecting a primitive value.
  • There’s an ambiguity or you want to override the inferred type.

The @Inject() decorator is used on constructor parameters to specify the injection token.

// src/services/greeter.service.ts
import { Injectable, Inject } from 'injection-js';
import { GREETING_MESSAGE } from '../tokens';
import { Logger } from './logger';

@Injectable()
export class GreeterService {
  constructor(
    @Inject(GREETING_MESSAGE) private message: string, // Explicitly inject using the token
    private logger: Logger                             // Inferred by type metadata
  ) {
    this.logger.log(`GreeterService initialized with message: "${this.message}"`);
  }

  sayHello(name: string): string {
    return `${this.message}, ${name}!`;
  }
}

Here, GREETING_MESSAGE is an InjectionToken, so we must use @Inject() to tell injection-js to look for a provider mapped to that token. For Logger, because it’s a class and is @Injectable(), injection-js can infer its type from the constructor signature and resolve it automatically.

Putting it all together: Building an Injector

The ReflectiveInjector is the core class responsible for creating and managing dependencies. You create an injector by providing a list of providers.

// src/app.ts
import "reflect-metadata"; // KEEP THIS AT THE TOP!
import { ReflectiveInjector, Injectable, Inject, InjectionToken } from 'injection-js';

// --- Services & Tokens (normally in separate files) ---

// src/services/logger.ts
@Injectable()
export class Logger {
  log(message: string): void {
    const timestamp = new Date().toISOString();
    console.log(`[${timestamp}] LOG: ${message}`);
  }
  error(message: string): void {
    const timestamp = new Date().toISOString();
    console.error(`[${timestamp}] ERROR: ${message}`);
  }
}

// src/tokens.ts
export const GREETING_MESSAGE = new InjectionToken<string>('GREETING_MESSAGE');

export interface AppConfig {
  apiUrl: string;
  environment: 'development' | 'production';
}
export const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG');

// src/services/data.service.ts
@Injectable()
export class DataService {
  constructor(private logger: Logger, @Inject(APP_CONFIG) private config: AppConfig) {
    this.logger.log(`DataService initialized for API: ${config.apiUrl}`);
  }

  getSomeData(): string {
    this.logger.log("Fetching some data...");
    return "Here's your data!";
  }
}

// src/services/greeter.service.ts
@Injectable()
export class GreeterService {
  constructor(
    @Inject(GREETING_MESSAGE) private message: string,
    private logger: Logger
  ) {
    this.logger.log(`GreeterService initialized with message: "${this.message}"`);
  }

  sayHello(name: string): string {
    return `${this.message}, ${name}!`;
  }
}

// --- Application entry point ---

const myAppConfig: AppConfig = {
  apiUrl: 'https://api.myawesomeapp.com/v1',
  environment: 'development'
};

const providers = [
  Logger, // shorthand for { provide: Logger, useClass: Logger }
  { provide: GREETING_MESSAGE, useValue: "Hello from Injection-JS" },
  { provide: APP_CONFIG, useValue: myAppConfig },
  DataService,
  GreeterService
];

// Create the injector
const injector = ReflectiveInjector.resolveAndCreate(providers);

// Resolve and use services
const greeter = injector.get(GreeterService);
console.log(greeter.sayHello("World"));

const dataService = injector.get(DataService);
console.log(dataService.getSomeData());

const loggerInstance = injector.get(Logger);
loggerInstance.log("Application finished successfully.");

// Let's try getting the config directly
const configFromInjector = injector.get(APP_CONFIG);
console.log(`Config environment from injector: ${configFromInjector.environment}`);

When you run this app.ts (using npm start), you’ll see the services being initialized and logging messages, demonstrating the power of Injection-JS providing dependencies automatically.

Exercises / Mini-Challenges

Learning by doing is key! Try these challenges to solidify your understanding.

Exercise 2.1: Implement a ConsoleWriter

Objective: Create a low-level service and inject it into the Logger to make the Logger more flexible.

  1. Create a new file src/services/console-writer.ts:

    // src/services/console-writer.ts
    import { Injectable } from 'injection-js';
    
    @Injectable()
    export class ConsoleWriter {
      write(message: string): void {
        console.log(message);
      }
      writeError(message: string): void {
        console.error(message);
      }
    }
    
  2. Modify src/services/logger.ts:

    • Import ConsoleWriter.
    • Inject ConsoleWriter into the Logger’s constructor.
    • Update log and error methods to use this.consoleWriter.write() and this.consoleWriter.writeError() instead of directly console.log and console.error.
    // src/services/logger.ts
    import "reflect-metadata";
    import { Injectable } from "injection-js";
    import { ConsoleWriter } from "./console-writer"; // New import
    
    @Injectable()
    export class Logger {
      constructor(private consoleWriter: ConsoleWriter) {} // Inject ConsoleWriter
    
      log(message: string): void {
        const timestamp = new Date().toISOString();
        this.consoleWriter.write(`[${timestamp}] LOG: ${message}`);
      }
    
      error(message: string): void {
        const timestamp = new Date().toISOString();
        this.consoleWriter.writeError(`[${timestamp}] ERROR: ${message}`);
      }
    }
    
  3. Update app.ts providers: Ensure ConsoleWriter is added to the ReflectiveInjector.resolveAndCreate array of providers.

    // ... (imports and other service definitions)
    const providers = [
      ConsoleWriter, // Add ConsoleWriter here
      Logger,
      { provide: GREETING_MESSAGE, useValue: "Hello from Injection-JS" },
      { provide: APP_CONFIG, useValue: myAppConfig },
      DataService,
      GreeterService
    ];
    // ...
    
  4. Run npm start: Verify that the output is still the same. The internal implementation of Logger has changed, but its public API and the application’s overall behavior remain consistent, demonstrating modularity.

Exercise 2.2: Conditional Logger with Factory Provider

Objective: Create a FileLogger and use a factory provider to conditionally provide either Logger (console) or FileLogger based on the AppConfig environment.

  1. Create a new file src/services/file-logger.ts:

    // src/services/file-logger.ts
    import { Injectable } from 'injection-js';
    import * as fs from 'fs'; // Node.js file system module
    
    @Injectable()
    export class FileLogger {
      private logFilePath = 'app.log';
    
      constructor() {
        // Ensure log file exists or is created
        fs.writeFileSync(this.logFilePath, '', { flag: 'a+' });
      }
    
      log(message: string): void {
        const timestamp = new Date().toISOString();
        fs.appendFileSync(this.logFilePath, `[${timestamp}] FILE LOG: ${message}\n`);
      }
    
      error(message: string): void {
        const timestamp = new Date().toISOString();
        fs.appendFileSync(this.logFilePath, `[${timestamp}] FILE ERROR: ${message}\n`);
      }
    }
    
  2. Modify src/app.ts:

    • Import FileLogger.
    • Define a factory function that decides which logger to provide based on APP_CONFIG.
    • Update the providers array to use this factory.
    // src/app.ts (add this import)
    import { FileLogger } from './services/file-logger';
    // ... other imports
    
    // Factory function to create logger based on environment
    const loggerFactory = (config: AppConfig, consoleWriter: ConsoleWriter) => {
      if (config.environment === 'production') {
        return new FileLogger();
      } else {
        return new Logger(consoleWriter); // Re-use our ConsoleLogger with ConsoleWriter
      }
    };
    
    const providers = [
      ConsoleWriter, // ConsoleWriter is always needed for the default Logger
      // { provide: Logger, useClass: Logger }, // Remove this, as we'll use the factory
      // Add a provider for Logger that uses the factory
      {
        provide: Logger, // When Logger is requested
        useFactory: loggerFactory, // use this factory function
        deps: [APP_CONFIG, ConsoleWriter] // provide AppConfig and ConsoleWriter to the factory
      },
      // ... (rest of your providers)
      { provide: GREETING_MESSAGE, useValue: "Hello from Injection-JS" },
      { provide: APP_CONFIG, useValue: myAppConfig },
      DataService,
      GreeterService
    ];
    
    // ... (rest of the app.ts)
    
  3. Test:

    • First, set myAppConfig.environment = 'development' in app.ts and run npm start. You should see console logs.
    • Then, change myAppConfig.environment = 'production' in app.ts and run npm start.
      • You should now see logs appear in a newly created app.log file in your project root, and no console logs from the Logger or DataService (only initial logs from GreeterService might still go to console if it uses console.log directly, but the injected logger will write to file).

This exercise beautifully demonstrates the power of factory providers for dynamic and conditional dependency resolution, a core feature of robust DI systems.