Best Practices and Common Patterns with Injection-JS

5. Best Practices and Common Patterns

Mastering a library like Injection-JS isn’t just about knowing its features; it’s about applying them effectively to write clean, maintainable, and robust code. This chapter covers essential best practices, common patterns, and potential pitfalls to help you leverage Injection-JS to its fullest.

Adhering to SOLID Principles

Dependency Injection itself is a powerful enabler of SOLID principles, which are fundamental to good software design.

  • Single Responsibility Principle (SRP): Each class should have only one reason to change. DI helps by allowing a class to focus on its primary responsibility, delegating concerns like logging, data access, or authentication to injected dependencies.
    • Bad: A UserService handles user logic, and connects to the database, and logs messages.
    • Good: UserService handles user logic and injects UserRepository (for DB access) and Logger (for logging).
  • Open/Closed Principle (OCP): Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification. With DI, you can introduce new implementations of an interface or abstract class without modifying the consumer of that dependency. This is especially clear with useClass, useFactory, and useExisting.
    • Example: If a NotificationService is injected, you can switch from EmailNotificationService to SMSNotificationService by changing a provider, not the client code.
  • Liskov Substitution Principle (LSP): Objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program. When you inject an abstraction (like an interface represented by an InjectionToken or a base class), you can substitute different concrete implementations, and the client code should still work correctly.
  • Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions. DI enforces this by making high-level classes depend on abstract InjectionTokens or interface-like class types, rather than concrete implementations. The injector then provides the concrete details.

When to Use Which Provider Type

  • useClass:

    • When: Most common scenario. When you need a new instance of a class, and that class has a straightforward constructor that the injector can resolve.
    • Best For: Services, components, repositories, etc., that have their own dependencies resolved via their constructor.
    • Example: Logger, UserService.
  • useValue:

    • When: To provide a fixed, pre-existing JavaScript value (string, number, object, array, function) or an already instantiated object.
    • Best For: Configuration objects (APP_CONFIG), global constants, environment variables, or mock objects in tests.
    • Example: { provide: API_URL, useValue: 'https://api.example.com' }.
  • useFactory:

    • When: When the creation logic of a dependency is complex, conditional, or requires dynamic inputs not available during static provider definition. The factory function itself can depend on other injected services (deps array).
    • Best For: Services whose instantiation depends on runtime conditions (e.g., environment), or services that require some setup logic before being returned.
    • Example: analyticsServiceFactory creating different AnalyticsService instances based on AppConfig.environment.
  • useExisting:

    • When: To create an alias for an existing dependency. When you want a specific token to resolve to the same instance that another token resolves to. This is useful for providing multiple names for the same service or for implementing abstract class patterns.
    • Best For: Implementing interfaces, providing alternative names, or ensuring a specific concrete implementation is returned when an abstraction is requested.
    • Example: { provide: AbstractLogger, useExisting: FancyLogger }.

Leveraging InjectionToken

  • Use InjectionToken for non-class dependencies: Always use InjectionToken for primitive values, plain objects, functions, or when providing an abstraction that isn’t a class (like an interface). This ensures unique identification and strong typing.
  • Descriptive names: The string passed to new InjectionToken<T>('YOUR_DESCRIPTIVE_NAME') is used in debugging messages. Make it clear what the token represents.

Architecting with Injector Hierarchies

  • Root Injector for Singletons: Services that should be application-wide singletons (e.g., Logger, AuthService, ConfigurationService) should be provided at the root injector.
  • Child Injectors for Scoped Services: Use child injectors for services that need to be instantiated once per request, per module, or per feature. This helps with resource management and isolation.
  • Clear Ownership: Each module or feature should be responsible for its own providers and services, registering them in its own injector.
  • Avoid Overuse of Injector.get(): While injector.get() allows dynamic resolution, prefer constructor injection (@Inject()) whenever possible. Constructor injection makes dependencies explicit, improving readability and testability. Use Injector.get() sparingly, for truly dynamic or lazy-loaded scenarios where constructor injection isn’t feasible.

Handling Circular Dependencies

A circular dependency occurs when ServiceA depends on ServiceB, and ServiceB depends on ServiceA. This can lead to runtime errors or undefined values during initialization.

// Example of a circular dependency
@Injectable()
class ServiceA {
  constructor(private serviceB: ServiceB) {} // Depends on ServiceB
}

@Injectable()
class ServiceB {
  constructor(private serviceA: ServiceA) {} // Depends on ServiceA
}

Injection-JS (like Angular’s DI) can sometimes resolve simple circular class dependencies if one side of the circle is resolved lazily, but it’s generally an anti-pattern.

Strategies to resolve circular dependencies:

  1. Refactor: The best solution is to refactor your code. Often, a circular dependency indicates a violation of SRP or a poorly defined boundary. Can you extract a shared dependency that both services use?

  2. Introduce an Abstraction: Both services can depend on an interface (represented by an InjectionToken) instead of concrete implementations.

  3. Lazy Resolution (using Injector): If refactoring isn’t immediately possible, you can break the circularity by injecting the Injector itself and resolving one dependency dynamically after construction. This should be used as a last resort.

    @Injectable()
    class ServiceA {
      constructor(private injector: Injector) {} // Inject Injector instead of ServiceB directly
    
      // Access ServiceB lazily
      getServiceB(): ServiceB {
        return this.injector.get(ServiceB);
      }
    }
    
    @Injectable()
    class ServiceB {
      constructor(private serviceA: ServiceA) {} // Still depends on ServiceA
    }
    

    Now ServiceA doesn’t demand ServiceB at construction time. ServiceB can be fully constructed, then ServiceA’s constructor finishes, and later, ServiceA can get ServiceB when needed.

Testing with Injection-JS

One of the biggest benefits of DI is improved testability.

  • Mocking Dependencies: For unit tests, you can easily provide mock or stub implementations of services using useValue or useClass in a local ReflectiveInjector.
  • Isolated Testing: Create a new ReflectiveInjector for each test or test suite to ensure complete isolation and prevent side effects between tests.
// src/tests/user.service.spec.ts (example mock setup)
import "reflect-metadata";
import { ReflectiveInjector, Injectable } from 'injection-js';

// Assume these are your real services
@Injectable()
export class Logger {
  log(message: string) { /* ... */ }
}

@Injectable()
export class UserRepository {
  getUsers(): string[] { return ['Alice', 'Bob']; }
}

@Injectable()
export class UserService {
  constructor(private logger: Logger, private userRepository: UserRepository) {}
  getAllUserNames(): string[] {
    this.logger.log('Fetching all users...');
    return this.userRepository.getUsers();
  }
}

// --- Test Setup ---
describe('UserService', () => {
  let injector: ReflectiveInjector;
  let userService: UserService;
  let mockLogger: Partial<Logger>;
  let mockUserRepository: Partial<UserRepository>;

  beforeEach(() => {
    // Create mock objects
    mockLogger = {
      log: jest.fn() // Use a mocking library like Jest for spy functions
    };
    mockUserRepository = {
      getUsers: jest.fn(() => ['MockUser1', 'MockUser2'])
    };

    // Configure injector with mocks
    injector = ReflectiveInjector.resolveAndCreate([
      { provide: Logger, useValue: mockLogger }, // Provide mock logger
      { provide: UserRepository, useValue: mockUserRepository }, // Provide mock repo
      UserService // The service under test
    ]);

    userService = injector.get(UserService);
  });

  it('should return mock users and log the action', () => {
    const users = userService.getAllUserNames();
    expect(users).toEqual(['MockUser1', 'MockUser2']);
    expect(mockLogger.log).toHaveBeenCalledWith('Fetching all users...');
    expect(mockUserRepository.getUsers).toHaveBeenCalledTimes(1);
  });

  it('should be a new instance for each test suite run', () => {
    const anotherUserService = injector.get(UserService);
    expect(anotherUserService).not.toBe(userService); // Depends on how test runner re-creates things
  });
});

Common Pitfalls and How to Avoid Them

  1. Forgetting reflect-metadata import: Always import "reflect-metadata"; at the very top of your application’s entry point, or at the top of shared files that might be imported first. Without it, decorator metadata won’t be available, and injection-js won’t know how to resolve class dependencies.
  2. Incorrect tsconfig.json: Ensure experimentalDecorators and emitDecoratorMetadata are true in your tsconfig.json.
  3. Missing @Injectable(): Classes that have dependencies or are intended to be injected often need @Injectable() to register their metadata with the DI system, even if they don’t have explicit constructor parameters.
  4. Not providing a dependency: If you try to injector.get(Something) but no provider for Something is registered in the injector or its parents, you’ll get an error: No provider for Something!. Always ensure a provider exists.
  5. Confusing useValue vs. useClass vs. useFactory:
    • useValue: Provides the exact value. Doesn’t instantiate.
    • useClass: Instantiates the specified class.
    • useFactory: Executes a function to get the value/instance. Be clear about which one you need.
  6. Over-reliance on Injector.get(): As mentioned, prefer constructor injection. It’s more declarative and easier to reason about.
  7. Deeply Nested Injector Hierarchies: While powerful, overly complex hierarchies can make debugging and understanding the application flow difficult. Strive for a balance between isolation and simplicity.

By following these best practices, you can design robust, maintainable, and scalable applications using Injection-JS. In the next section, we’ll apply these concepts in guided projects.

Exercises / Mini-Challenges

Exercise 5.1: Refactor for SRP with DI

Objective: Take a “god service” and refactor it into smaller services using DI.

  1. Start with a ReportGenerator (God Service):

    // src/bad-report-generator.ts
    class BadReportGenerator {
      generateMonthlyReport(month: string, year: number): string {
        console.log(`[ReportGenerator] Generating report for ${month}, ${year}...`);
    
        // Simulate fetching data
        const rawData = this.fetchSalesData(month, year);
    
        // Simulate processing data
        const processedData = this.processData(rawData);
    
        // Simulate formatting
        const formattedReport = this.formatReport(processedData);
    
        // Simulate sending email
        this.sendEmail(formattedReport);
    
        console.log(`[ReportGenerator] Report generation complete.`);
        return formattedReport;
      }
    
      private fetchSalesData(month: string, year: number): any[] {
        console.log("  Fetching sales data...");
        return [{ product: 'A', sales: 100 }, { product: 'B', sales: 150 }];
      }
    
      private processData(data: any[]): any {
        console.log("  Processing data...");
        return data.reduce((acc, item) => acc + item.sales, 0); // Total sales
      }
    
      private formatReport(totalSales: number): string {
        console.log("  Formatting report...");
        return `Monthly Sales Report: Total Sales = $${totalSales}`;
      }
    
      private sendEmail(reportContent: string): void {
        console.log("  Sending email...");
        // Imagine sending an actual email here
        console.log(`  Email sent with content: "${reportContent}"`);
      }
    }
    
    // How it's currently used:
    // const generator = new BadReportGenerator();
    // generator.generateMonthlyReport("October", 2025);
    
  2. Refactor using DI:

    • Create separate @Injectable() services for SalesDataSource, DataProcessor, ReportFormatter, and EmailSender.
    • Modify ReportGenerator to inject these services and delegate responsibilities to them.
    • Update your app.ts to provide all these new services and ReportGenerator.
    // src/refactored-report-generator.ts
    import "reflect-metadata";
    import { ReflectiveInjector, Injectable } from 'injection-js';
    
    // 1. Sales Data Source
    @Injectable()
    export class SalesDataSource {
      fetchSalesData(month: string, year: number): any[] {
        console.log("  [SalesDataSource] Fetching sales data...");
        return [{ product: 'A', sales: 100 }, { product: 'B', sales: 150 }];
      }
    }
    
    // 2. Data Processor
    @Injectable()
    export class DataProcessor {
      processData(data: any[]): number {
        console.log("  [DataProcessor] Processing data...");
        return data.reduce((acc, item) => acc + item.sales, 0);
      }
    }
    
    // 3. Report Formatter
    @Injectable()
    export class ReportFormatter {
      formatReport(totalSales: number, month: string, year: number): string {
        console.log("  [ReportFormatter] Formatting report...");
        return `Monthly Sales Report for ${month}, ${year}: Total Sales = $${totalSales}`;
      }
    }
    
    // 4. Email Sender
    @Injectable()
    export class EmailSender {
      sendEmail(recipient: string, subject: string, body: string): void {
        console.log(`  [EmailSender] Sending email to ${recipient} with subject "${subject}"...`);
        console.log(`  Email content: "${body}"`);
      }
    }
    
    // 5. Refactored ReportGenerator
    @Injectable()
    export class ReportGenerator {
      constructor(
        private salesDataSource: SalesDataSource,
        private dataProcessor: DataProcessor,
        private reportFormatter: ReportFormatter,
        private emailSender: EmailSender
      ) {}
    
      generateMonthlyReport(month: string, year: number): string {
        console.log(`\n[ReportGenerator] Generating report for ${month}, ${year}...`);
    
        const rawData = this.salesDataSource.fetchSalesData(month, year);
        const processedData = this.dataProcessor.processData(rawData);
        const formattedReport = this.reportFormatter.formatReport(processedData, month, year);
    
        this.emailSender.sendEmail("reports@example.com", "Monthly Sales", formattedReport);
    
        console.log(`[ReportGenerator] Report generation complete.`);
        return formattedReport;
      }
    }
    
    // --- Using the refactored system ---
    const providers = [
      SalesDataSource,
      DataProcessor,
      ReportFormatter,
      EmailSender,
      ReportGenerator
    ];
    
    const injector = ReflectiveInjector.resolveAndCreate(providers);
    const reportGenerator = injector.get(ReportGenerator);
    
    reportGenerator.generateMonthlyReport("October", 2025);
    

    Compare the BadReportGenerator with the ReportGenerator. The DI version is much more modular, testable, and adheres to SRP.

Exercise 5.2: Conditional Email Sender with useFactory

Objective: Enhance the ReportGenerator system from Exercise 5.1 to conditionally use a MockEmailSender in development and the real EmailSender in production, using a useFactory provider and APP_CONFIG.

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

  2. Create a MockEmailSender:

    // src/services/mock-email-sender.ts
    import { Injectable } from 'injection-js';
    import { EmailSender } from './refactored-report-generator'; // Assuming EmailSender exists
    
    @Injectable()
    export class MockEmailSender extends EmailSender {
      override sendEmail(recipient: string, subject: string, body: string): void {
        console.warn(`  [MockEmailSender] MOCK: Would send email to ${recipient} (Subject: "${subject}") with content: "${body}"`);
      }
    }
    
  3. Modify app.ts (or conditional-email.ts):

    • Define an AppConfig with environment: 'development' or 'production'.
    • Create a emailSenderFactory function that decides whether to return EmailSender or MockEmailSender based on the AppConfig.
    • Update the providers array to use this factory for EmailSender.
    • Run with both development and production environments to see the difference.
    // src/conditional-email.ts
    import "reflect-metadata";
    import { ReflectiveInjector, Injectable, InjectionToken, Inject } from 'injection-js';
    
    // --- Re-using and simplifying previous service definitions ---
    // (In a real app, these would be separate files)
    @Injectable()
    export class SalesDataSource { /* ... */ }
    
    @Injectable()
    export class DataProcessor { /* ... */ }
    
    @Injectable()
    export class ReportFormatter { /* ... */ }
    
    // Base EmailSender
    @Injectable()
    export class EmailSender {
      sendEmail(recipient: string, subject: string, body: string): void {
        console.log(`  [REAL EmailSender] Sending email to ${recipient} (Subject: "${subject}") with content: "${body}"`);
      }
    }
    
    // Mock EmailSender
    @Injectable()
    export class MockEmailSender extends EmailSender {
      override sendEmail(recipient: string, subject: string, body: string): void {
        console.warn(`  [MockEmailSender] MOCK: Would send email to ${recipient} (Subject: "${subject}") with content: "${body}"`);
      }
    }
    
    // AppConfig token
    export interface AppConfig { apiUrl: string; environment: 'development' | 'production'; }
    export const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG');
    
    // Refactored ReportGenerator (depends on EmailSender, which will be provided by factory)
    @Injectable()
    export class ReportGenerator {
      constructor(
        private salesDataSource: SalesDataSource,
        private dataProcessor: DataProcessor,
        private reportFormatter: ReportFormatter,
        private emailSender: EmailSender // Injected via factory
      ) {}
    
      generateMonthlyReport(month: string, year: number): string {
        console.log(`\n[ReportGenerator] Generating report for ${month}, ${year}...`);
        const rawData = this.salesDataSource.fetchSalesData(month, year);
        const processedData = this.dataProcessor.processData(rawData);
        const formattedReport = this.reportFormatter.formatReport(processedData, month, year);
        this.emailSender.sendEmail("reports@example.com", "Monthly Sales", formattedReport);
        console.log(`[ReportGenerator] Report generation complete.`);
        return formattedReport;
      }
    }
    // --- End Service Definitions ---
    
    // Factory to provide the correct EmailSender based on environment
    const emailSenderFactory = (config: AppConfig) => {
      if (config.environment === 'production') {
        return new EmailSender();
      } else {
        return new MockEmailSender();
      }
    };
    
    // Scenario 1: Development Environment
    const devAppConfig: AppConfig = { apiUrl: 'dev-api.com', environment: 'development' };
    const devProviders = [
      SalesDataSource,
      DataProcessor,
      ReportFormatter,
      // Provide APP_CONFIG
      { provide: APP_CONFIG, useValue: devAppConfig },
      // Provide EmailSender using the factory
      { provide: EmailSender, useFactory: emailSenderFactory, deps: [APP_CONFIG] },
      ReportGenerator
    ];
    
    console.log("--- Running in Development Environment ---");
    const devInjector = ReflectiveInjector.resolveAndCreate(devProviders);
    const devReportGenerator = devInjector.get(ReportGenerator);
    devReportGenerator.generateMonthlyReport("November", 2025);
    
    
    // Scenario 2: Production Environment
    const prodAppConfig: AppConfig = { apiUrl: 'prod-api.com', environment: 'production' };
    const prodProviders = [
      SalesDataSource,
      DataProcessor,
      ReportFormatter,
      { provide: APP_CONFIG, useValue: prodAppConfig },
      { provide: EmailSender, useFactory: emailSenderFactory, deps: [APP_CONFIG] },
      ReportGenerator
    ];
    
    console.log("\n--- Running in Production Environment ---");
    const prodInjector = ReflectiveInjector.resolveAndCreate(prodProviders);
    const prodReportGenerator = prodInjector.get(ReportGenerator);
    prodReportGenerator.generateMonthlyReport("November", 2025);
    
    // Clean up env vars (important for subsequent tests/runs)
    delete process.env.DB_URL;
    delete process.env.API_URL;
    delete process.env.DEBUG_MODE;
    

    This exercise perfectly demonstrates how to use useFactory for environment-specific dependency swapping, a critical pattern for robust applications.