Angular Dependency Injection: The Ultimate Guide

June 13, 2024

Angular Dependency Injection: The Ultimate Guide

In modern web development, building scalable and maintainable applications requires a framework that efficiently manages the relationships between different components. Angular, one of the most popular frameworks, excels in this regard, thanks in large part to its robust Dependency Injection (DI) system.

What is Angular Dependency Injection?

Dependency Injection (DI) is a design pattern used to implement Inversion of Control (IoC), a principle that promotes the decoupling of software components. In simple terms, DI allows a class to receive its dependencies from an external source rather than creating them itself. This external source can be another class, a configuration file, or a framework.

In Angular, DI is at the core of its architecture, facilitating the efficient management of service instances and their dependencies. When you build Angular applications, you rely on DI to supply your components with the necessary services and resources without manually instantiating them. This not only makes your code cleaner and more modular but also enhances its testability and flexibility. Explore an in-depth tutorial on Angular directives for a comprehensive understanding of how they enhance HTML with custom attributes and tags.

Dependency Injection involves three main roles:

  1. Client: The component or class that requires a service to function.
  2. Service: The object or resource that provides specific functionality required by the client.
  3. Injector: The mechanism that delivers the service to the client. In Angular, this is typically the framework itself or a configuration defined by the developer.

Inversion of Control (IoC)

IoC is a principle where the control of object creation and dependency management is shifted from the object itself to an external entity. In traditional programming, a class is responsible for creating its dependencies, leading to tightly coupled code. IoC reverses this control, allowing an external framework or system to manage these responsibilities, resulting in more flexible and modular applications.

Types of Dependency Injection

There are several ways to implement DI, each with its own use cases and benefits:

1. Constructor Injection: Dependencies are provided through a class constructor. This is the most common form of DI in Angular, where services are injected into components or other services via their constructors.

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Injectable({
  providedIn: 'root'
})
export class DataService {
  constructor(private http: HttpClient) { }
}

2. Setter Injection: Dependencies are provided through setter methods. This allows the modification of dependencies after the object has been constructed. Although less common in Angular, it can be useful for optional dependencies or for dependencies that may change during the object’s lifecycle.

export class SomeComponent {

  private dataService: DataService;

  setDataService(dataService: DataService) {

    this.dataService = dataService;

  }

}

3. Interface Injection: The dependency provides a method that the client must call to receive the service. This approach is not typically used in Angular but is seen in other frameworks and contexts.

Benefits of Dependency Injection

Implementing DI offers several advantages:

  • Decoupling: By separating the creation and usage of dependencies, DI reduces the tight coupling between components, making the code more modular and easier to maintain.
  • Ease of Testing: DI facilitates unit testing by allowing mock objects or stubs to be injected into components, leading to isolated and reliable tests.
  • Code Reusability: Services and components can be easily reused across different parts of the application without rewriting or duplicating code.
  • Scalability: DI makes it easier to manage and extend complex applications, as new services can be integrated without altering the existing codebase significantly.

Dependency Injection in Angular Context

In Angular, DI is seamlessly integrated into its framework, allowing for easy management of services and dependencies across various components and modules. Angular’s DI system uses injectors to resolve and provide services to the components that need them, promoting a clean and maintainable architecture.

Understanding DI is crucial for Angular developers as it underpins the framework’s approach to building modular and scalable applications. By mastering DI, developers can leverage Angular’s full potential to create efficient, robust, and testable code.

Practical Example

Consider a scenario where an Angular component needs to fetch data from an API. Without DI, the component might directly create an instance of the HTTP service, tightly coupling it to the service and making it difficult to replace or mock for testing:

export class NoDiComponent {
  private http: HttpClient;

  constructor() {
    this.http = new HttpClient(); // Tight coupling
  }
}

With DI, the HTTP service is injected into the component, promoting loose coupling and easier testing:
import { HttpClient } from '@angular/common/http';

export class DiComponent {
  constructor(private http: HttpClient) { } // Loose coupling
}

In the DI example, Angular’s injector takes care of providing the HttpClient instance, making the component cleaner and more focused on its own functionality.

Why Use Dependency Injection in Angular?

Dependency Injection (DI) is a core feature of Angular that brings several powerful advantages to web development. Here’s why DI is essential in Angular applications:

1. Enhanced Code Maintainability

Dependency Injection decouples the creation and management of dependencies from the components that use them. This separation makes it easier to update, refactor, or replace services without modifying the components that depend on them. For instance, if you need to update a logging service, you can do so without touching every component that uses logging. This flexibility is crucial for maintaining and scaling large applications.

2. Improved Testability

Testing components in isolation is simpler when using DI. With DI, you can inject mock services into components during testing, allowing you to verify the component’s behavior without relying on real services. This leads to more focused and reliable unit tests. For example, instead of testing a component’s interaction with a live API, you can inject a mock API service to simulate different scenarios and responses, ensuring that the component handles all cases correctly.

3. Promotes Reusable and Modular Code

By using DI, you define services and their dependencies in a way that makes them reusable across different parts of the application. Angular’s DI framework allows services to be shared among multiple components or modules, reducing duplication and encouraging a DRY (Don’t Repeat Yourself) codebase. This modular approach facilitates easier integration of new features and services as your application grows.

4. Flexible Service Configuration

Angular’s DI system provides a flexible way to configure which services to inject based on different conditions. For example, you can configure different service implementations for development and production environments. This flexibility extends to how services are provided, allowing for complex dependency graphs and configurations without hardcoding dependencies.

5. Optimized Performance with Hierarchical Injectors

Angular’s hierarchical injector system allows for efficient service management across different scopes. Services can be configured at the root level, module level, or component level, which helps control their lifetime and scope. For example, a service provided at the root level is shared across the entire application, whereas a service provided at a component level is unique to that component and its children. This approach optimizes resource usage and enhances performance.

6. Simplified Component and Service Interaction

With DI, components in Angular focus on their primary responsibilities without worrying about how their dependencies are created or managed. This simplification leads to cleaner and more understandable code. For example, a component that displays user data can rely on a user service to fetch and provide the data, focusing solely on presentation logic.

7. Support for Lazy Loading and Optimization

Angular’s DI system integrates seamlessly with features like lazy loading, where services and modules are loaded only when needed. This integration helps improve the performance and efficiency of your applications by reducing the initial load time and resource usage.

Core Concepts of Angular Dependency Injection

To effectively utilize Dependency Injection (DI) in Angular, understanding its core concepts is essential. Angular’s DI system is robust and versatile, allowing for efficient management of services and their dependencies.

1. Angular Services and Injectors

Services are classes that handle specific tasks or business logic, such as fetching data from an API or managing user sessions. In Angular, services are defined using the @Injectable decorator, which makes them available for DI.

Injectors are responsible for creating instances of services and injecting them into components or other services as needed. Angular’s injector is hierarchical, meaning that each level of the application (root, module, component) can have its own injector, which contributes to how services are provided and shared across different parts of the application.

2. Angular Providers

Providers are declarations that inform Angular on how to create and supply instances of services. There are several ways to configure providers in Angular, each offering different levels of control over how services are created and managed:

1. Class Providers: The most common type, where the provider token and the service class are the same. This is typically done using the providedIn property in the @Injectable decorator.

@Injectable({
  providedIn: 'root'
})
export class DataService { }

2. Alias Providers: Use the useClass option to provide an alias for a service, allowing you to substitute one implementation for another without changing the consumer code.

{ provide: SomeService, useClass: AnotherService }

3. Value Providers: Use the useValue option to provide a constant value or object.

{ provide: API_URL, useValue: 'https://api.example.com' }

4. Factory Providers: Use the useFactory option to create services dynamically using a factory function. This is useful for complex initialization logic.

{ provide: DataService, useFactory: dataServiceFactory, deps: [HttpClient] }

5. Existing Providers: Use the useExisting option to use an existing token as an alias for another.

{ provide: LoggerService, useExisting: ConsoleLoggerService }

Hierarchical Injector System

Angular’s DI system is hierarchical, meaning injectors are organized in a tree structure that mirrors the component tree. This structure allows different levels of granularity in service provision:

  • Root Injector: The top-level injector that provides services application-wide. Services provided here are singleton by default and shared across the entire app.
  • Module-level Injector: Provides services specific to a module. Useful for services that should only be available within a particular module.
  • Component-level Injector: Provides services to a specific component and its children. This allows fine-grained control over service scope, especially for stateful or transient services.

Each level of the hierarchy can provide its own set of services, or it can rely on the parent injector to provide them. When a service is requested, Angular starts at the component’s injector and moves up the hierarchy until it finds a provider.

Lifecycle and Scope of Services

The lifecycle and scope of services in Angular depend on where they are provided:

  • Singleton Services: Provided at the root level, these services are created once and shared throughout the application. They are ideal for services that maintain shared state or perform application-wide tasks.
  • Scoped Services: Provided at the module or component level, these services are created and destroyed along with their respective scope. They are useful for state management within specific modules or components.

Practical Examples

Root-level Service:

@Injectable({
  providedIn: 'root'
})
export class AuthService {
  // Singleton service available throughout the app
}

Module-level Service:

@NgModule({
  providers: [FeatureService] // Service available only within this module
})
export class FeatureModule { }

Component-level Service:

@Component({
  selector: 'app-user-profile',
  providers: [ProfileService] // Service available only to this component and its children
})
export class UserProfileComponent { }

Implementing Dependency Injection in Angular

Implementing Dependency Injection (DI) in Angular is straightforward due to the framework’s built-in support. This section guides you through the practical steps of setting up and using DI in Angular applications, ensuring your components receive the necessary services seamlessly.

Setting Up Services with the @Injectable Decorator

Services in Angular are typically classes that perform a specific function and are made available for DI using the @Injectable decorator. This decorator marks the class as a service that can be injected into components or other services.

Basic Service Setup

Here’s a step-by-step guide to creating and injecting a simple service:

1. Create a Service: Define a service class and decorate it with @Injectable. Specify its scope using the providedIn property.

    import { Injectable } from '@angular/core';
    
    @Injectable({
      providedIn: 'root' // Makes the service available throughout the application
    })
    export class DataService {
      constructor() { }
    
      getData() {
        return 'Data from service';
      }
    }

    2. Inject the Service into a Component: Use Angular’s DI system to inject the DataService into a component’s constructor.

    import { Component, OnInit } from '@angular/core';
    import { DataService } from './data.service';
    
    @Component({
      selector: 'app-example',
      template: `<p>{{ data }}</p>`
    })
    export class ExampleComponent implements OnInit {
      data: string;
    
      // Inject DataService into the component
      constructor(private dataService: DataService) { }
    
      ngOnInit() {
        this.data = this.dataService.getData();
      }
    }

    In this example, DataService is created as a singleton service because it is provided at the root level (providedIn: 'root'). The ExampleComponent receives an instance of DataService through its constructor.

    Configuring Providers in Angular Modules

    Providers define how instances of services are created and managed. While the providedIn property is often used for root-level services, you can also configure providers in Angular modules to control the scope of services more finely.

    Module-level Providers

    To provide a service only within a specific module:

    1. Define the Service Without providedIn: Do not specify providedIn in the @Injectable decorator.

      @Injectable()
      export class FeatureService {
        constructor() { }
      }

      2. Add the Service to the Module’s Providers Array: Include the service in the providers array of the Angular module.

      import { NgModule } from '@angular/core';
      import { CommonModule } from '@angular/common';
      import { FeatureComponent } from './feature.component';
      import { FeatureService } from './feature.service';
      
      @NgModule({
        declarations: [FeatureComponent],
        imports: [CommonModule],
        providers: [FeatureService] // Service is available only within this module
      })
      export class FeatureModule { }

      This configuration ensures that FeatureService is only available to the components within FeatureModule.

      Component-level Providers

      To limit a service to a specific component and its children, configure it directly in the component’s providers array:

      1. Define the Service Without providedIn:

        @Injectable()
        export class LocalService {
        constructor() { }
        }

        2. Add the Service to the Component’s Providers Array:

        import { Component } from '@angular/core';
        import { LocalService } from './local.service';
        
        @Component({
          selector: 'app-local',
          template: `<p>Local Component</p>`,
          providers: [LocalService] // Service is scoped to this component and its children
        })
        export class LocalComponent {
          constructor(private localService: LocalService) { }
        }

        In this setup, LocalService is instantiated each time LocalComponent is created, and it is not shared outside of this component’s scope.

        Using Factory Providers for Dynamic Service Creation

        Factory providers are used to create services dynamically using factory functions, which is useful for services that require complex initialization logic or external parameters at runtime.

        Creating a Factory Provider

        1. Define the Factory Function: Create a factory function that returns an instance of the service.

          export function dataServiceFactory(http: HttpClient): DataService {
            return new DataService(http);
          }

          2. Configure the Provider: Use the factory function in the module’s providers array.

          @NgModule({
            providers: [
              { provide: DataService, useFactory: dataServiceFactory, deps: [HttpClient] }
            ]
          })

          In this example, DataService is created by the dataServiceFactory function, which takes HttpClient as a dependency. This approach is helpful when service creation depends on runtime values or conditions.

          Handling Optional Dependencies with @Optional

          In some cases, a service might need to handle optional dependencies gracefully. Angular provides the @Optional decorator to achieve this.

          1. Mark Dependency as Optional:

            import { Optional } from ‘@angular/core’;

            @Injectable()
            export class OptionalService {
            constructor(@Optional() private configService: ConfigService) {
            if (configService) {
            // Use configService if available
            } else {
            // Handle absence of configService
            }
            }
            }

            In this example, ConfigService is injected only if it is available. If not, OptionalService handles the scenario where ConfigService is absent.

            Understanding Angular Injectors and Providers

            Angular’s Dependency Injection (DI) system revolves around injectors and providers. These components work together to deliver services to parts of your application that need them. Let’s explore how injectors and providers function in Angular’s DI framework.

            Angular Injectors

            An injector is a mechanism in Angular responsible for instantiating and managing dependencies. It acts as a registry of services that can be injected into components, services, or other Angular constructs.

            Angular injectors are hierarchical, forming a tree structure that mirrors the Angular component tree. Each node in the tree can have its own injector, which means different parts of your application can have different service configurations. This hierarchical approach allows for flexible and efficient service management.

            Types of Injectors

            1. Root Injector: This is the top-level injector and is created when the application starts. Services provided at this level are available globally and are typically singleton instances. If a service is provided in the root injector, it remains alive for the duration of the application.

              @Injectable({
              providedIn: ‘root’
              })
              export class GlobalService { }

              2. Module Injector: Each Angular module can have its own injector, which provides services to all components within that module. This is useful for modular applications where services are scoped to specific features.

              @NgModule({
              providers: [FeatureService]
              })
              export class FeatureModule { }

              3. Component Injector: Components can have their own injectors, which provide services specific to that component and its child components. This is particularly beneficial for services that should not be shared across different parts of the application.

              @Component({
                selector: 'app-local',
                template: '<p>Local Component</p>',
                providers: [LocalService]
              })
              export class LocalComponent { }

              Angular Providers

              Providers define how Angular should create and deliver instances of a service. They play a crucial role in the DI system by specifying the relationship between a token (which can be a class, string, or InjectionToken) and the service it provides.

              Provider Configuration Options

              1. Class Providers: These are the most common and straightforward providers where the service class is directly associated with the provider token.

                @Injectable({
                providedIn: ‘root’
                })
                export class ApiService { }

                2. Alias Providers (useClass): Allows you to substitute one service for another, which is useful when you want to provide a mock implementation for testing or a different implementation based on runtime conditions.

                { provide: LoggerService, useClass: ConsoleLoggerService }

                3. Value Providers (useValue): Used to provide a constant or an object as a service.

                { provide: API_ENDPOINT, useValue: 'https://api.example.com' }

                4. Factory Providers (useFactory): Use a factory function to create the service. This is ideal for services that require complex initialization logic or dependencies.

                { provide: ConfigService, useFactory: configServiceFactory, deps: [HttpClient] }

                5. Existing Providers (useExisting): Use an existing token as an alias for another service. This allows you to inject a service under multiple aliases.

                { provide: LoggerService, useExisting: AdvancedLoggerService }

                How Injectors Resolve Dependencies

                When a component or service requests a dependency, Angular’s DI system follows these steps to resolve it:

                1. Local Search: The injector starts by looking for a provider in the current context (component, module).
                2. Parent Search: If the provider is not found locally, the injector searches up the hierarchy, checking parent injectors until it reaches the root.
                3. Error Handling: If the provider is not found anywhere in the hierarchy, Angular throws a NullInjectorError.

                This hierarchical resolution process allows for flexible service provision and efficient memory usage. Services can be scoped to specific parts of the application, and common services can be shared across multiple components or modules.

                Lifecycle and Scope of Injected Services

                Services in Angular can have different lifecycles based on where they are provided:

                • Singleton Services: Services provided in the root injector are singletons, meaning they are instantiated once and shared across the entire application.
                • Scoped Services: Services provided at the module or component level are instantiated each time the respective module or component is created. They are not shared outside their scope.

                Understanding the lifecycle and scope of services is crucial for managing application state and performance. Singleton services are suitable for application-wide data or functionality, while scoped services are ideal for features or components that have specific, isolated needs.

                Example: Using Injectors and Providers

                Consider a scenario where you have a LoggerService used globally and a FeatureService used only within a specific module. Additionally, a LocalService is required only by a particular component:

                1. Global Service:

                  @Injectable({
                    providedIn: 'root'
                  })
                  export class LoggerService {
                    log(message: string) {
                      console.log(message);
                    }
                  }

                  2. Module Service:

                  @Injectable()
                  export class FeatureService {
                    constructor(private logger: LoggerService) { }
                  
                    getFeatureData() {
                      this.logger.log('Fetching feature data');
                      // Fetch data logic
                    }
                  }
                  
                  @NgModule({
                    providers: [FeatureService]
                  })
                  export class FeatureModule { }

                  3. Component Service:

                  @Injectable()
                  export class LocalService {
                    constructor(private logger: LoggerService) { }
                  
                    getLocalData() {
                      this.logger.log('Fetching local data');
                      // Fetch data logic
                    }
                  }
                  
                  @Component({
                    selector: 'app-local',
                    template: '<p>Local Component</p>',
                    providers: [LocalService]
                  })
                  export class LocalComponent {
                    constructor(private localService: LocalService) { }
                  }

                  In this setup, LoggerService is a singleton, FeatureService is scoped to the FeatureModule, and LocalService is specific to LocalComponent.

                  Advanced Angular Dependency Injection Techniques

                  After mastering the basics of Angular Dependency Injection (DI), understanding advanced techniques can significantly enhance your ability to manage complex dependencies and optimize your application. This section delves into more sophisticated uses of DI in Angular, including multi-providers, InjectionTokens, and control over dependency scopes.

                  Using Multi-Providers

                  Multi-providers allow you to provide multiple values for a single token. This is particularly useful when you need to aggregate several services or configurations under one token. For example, if you have multiple logging mechanisms and want them all to be invoked for every log message, you can use multi-providers.

                  Setting Up Multi-Providers

                  1. Define Multiple Providers: Use the multi: true property in the provider configuration to indicate that multiple values should be injected.

                    @Injectable()
                    export class ConsoleLoggerService {
                      log(message: string) {
                        console.log('Console Logger:', message);
                      }
                    }
                    
                    @Injectable()
                    export class FileLoggerService {
                      log(message: string) {
                        // Logic to log to a file
                      }
                    }

                    2. Configure the Multi-Provider:

                    import { InjectionToken } from '@angular/core';
                    
                    export const LOGGER_SERVICE = new InjectionToken<LoggerService[]>('LoggerService');
                    
                    @NgModule({
                      providers: [
                        { provide: LOGGER_SERVICE, useClass: ConsoleLoggerService, multi: true },
                        { provide: LOGGER_SERVICE, useClass: FileLoggerService, multi: true }
                      ]
                    })
                    export class AppModule { }

                    3. Inject and Use the Multi-Provider:

                    import { Inject, Component } from '@angular/core';
                    
                    @Component({
                      selector: 'app-logger',
                      template: '<p>Logger Component</p>'
                    })
                    export class LoggerComponent {
                      constructor(@Inject(LOGGER_SERVICE) private loggers: LoggerService[]) { }
                    
                      logMessage(message: string) {
                        this.loggers.forEach(logger => logger.log(message));
                      }
                    }

                    In this example, LoggerComponent injects an array of loggers, iterating over them to log a message through each logger service.

                    Using InjectionTokens

                    InjectionTokens are used to provide non-class dependencies in Angular. They offer a way to define and inject primitive values, configuration objects, or services with complex initialization that cannot be easily represented by a class.

                    Creating and Using InjectionTokens

                    1. Define an InjectionToken:

                      import { InjectionToken } from '@angular/core';
                      
                      export const API_ENDPOINT = new InjectionToken<string>('API_ENDPOINT');

                      2. Provide a Value for the InjectionToken:

                      @NgModule({
                        providers: [
                          { provide: API_ENDPOINT, useValue: 'https://api.example.com' }
                        ]
                      })
                      export class AppModule { }

                      3. Inject the InjectionToken:

                      import { Component, Inject } from '@angular/core';
                      
                      @Component({
                        selector: 'app-api',
                        template: '<p>API Component</p>'
                      })
                      export class ApiComponent {
                        constructor(@Inject(API_ENDPOINT) private apiEndpoint: string) { }
                      
                        getEndpoint() {
                          return this.apiEndpoint;
                        }
                      }

                      In this setup, ApiComponent injects the API_ENDPOINT token and uses its value to interact with the API.

                      Optional and Self Injections

                      Optional Injections allow you to handle cases where a dependency may or may not be available. Angular provides the @Optional decorator to inject a service only if it exists.

                      1. Mark Dependency as Optional:

                        import { Optional } from '@angular/core';
                        
                        @Injectable()
                        export class UserService {
                          constructor(@Optional() private logger?: LoggerService) {
                            if (logger) {
                              logger.log('UserService initialized');
                            }
                          }
                        }

                        In this example, LoggerService is only used if it is available, preventing errors if it’s not provided.

                        Self Injections restrict the injector to look only at the current injector and not climb up the hierarchy. This is useful when you want to ensure that a service is only resolved from the local injector.

                        2. Using @Self Decorator:

                          import { Self } from '@angular/core';
                          
                          @Injectable()
                          export class LocalService {
                            constructor(@Self() private logger: LoggerService) {
                              logger.log('LocalService initialized with local logger');
                            }
                          }

                          Here, LocalService ensures that LoggerService is resolved only from its local injector.

                          Advanced Factory Providers

                          Factory providers can be enhanced with more sophisticated logic to dynamically create services based on conditions or external data.

                          Complex Factory Function:

                          1. Define a Factory Function with Dependencies:

                            import { HttpClient } from '@angular/common/http';
                            
                            export function dynamicServiceFactory(http: HttpClient, config: AppConfig): DynamicService {
                              const service = new DynamicService(http);
                              service.configure(config);
                              return service;
                            }

                            2. Configure the Factory Provider:

                            import { AppConfig } from './app.config';
                            
                            @NgModule({
                              providers: [
                                { provide: DynamicService, useFactory: dynamicServiceFactory, deps: [HttpClient, AppConfig] }
                              ]
                            })
                            export class AppModule { }

                            In this setup, DynamicService is created by the factory function, which configures it using both HttpClient and AppConfig.

                            Using InjectionTokens for Complex Objects

                            When injecting complex objects or configurations, InjectionTokens offer a flexible way to define and provide these dependencies.

                            1. Define an InjectionToken for a Configuration Object:

                              import { InjectionToken } from '@angular/core';
                              
                              export interface AppConfig {
                                apiEndpoint: string;
                                timeout: number;
                              }
                              
                              export const APP_CONFIG = new InjectionToken<AppConfig>('app.config');

                              2. Provide the Configuration Object:

                              @NgModule({
                                providers: [
                                  {
                                    provide: APP_CONFIG,
                                    useValue: {
                                      apiEndpoint: 'https://api.example.com',
                                      timeout: 3000
                                    }
                                  }
                                ]
                              })
                              export class AppModule { }

                              3. Inject and Use the Configuration Object:

                              import { Component, Inject } from '@angular/core';
                              import { APP_CONFIG, AppConfig } from './app.config';
                              
                              @Component({
                                selector: 'app-config',
                                template: '<p>Config Component</p>'
                              })
                              export class ConfigComponent {
                                constructor(@Inject(APP_CONFIG) private config: AppConfig) { }
                              
                                getConfig() {
                                  return this.config;
                                }
                              }

                              In this example, ConfigComponent injects APP_CONFIG and accesses its properties.

                              Conclusion

                              Angular Dependency Injection (DI) is essential for building modular, maintainable, and scalable applications. It simplifies dependency management by decoupling services from components, enhances testability through easy injection of mock services, and promotes code reusability. Angular’s hierarchical injector system allows flexible service provisioning, and advanced techniques like multi-providers and InjectionTokens enable dynamic configurations. By following best practices and avoiding common pitfalls, developers can leverage DI to manage complex dependencies efficiently and create robust Angular applications that adapt to future needs.