Angular Dependency Injection Tips and Tricks

Categories

Dependency injection or DI is a fundamental concept in Angular. Classes with decorators (like @Injectable()or @Component()) can use DI to inject dependencies from the dependency tree. These can be provided locally or globally.

Dependency Injection Tree

In Angular dependency resolution follows a depth-first approach. This means that the first dependency that is resolved is the one that is used for the injection. This can be seen in the following diagram:

flowchart TD
    id1(ElementInjector Component) --> id2(ElementInjector Parent Component)
    id2 --> id3(ElementInjector App / Root Component)
    id3 -- If not resolved --> id4(EnvironmentInjector)
    id4 --> id5(PlatformInjector)
    id5 --> id6(NullInjector)
  

First the ElementInjector tries to resolve the dependency from the current component. If it can't find it in the current component, it will look in the parent component and so on. If it can't find the dependency in any of the parent components, it will look in the EnvironmentInjector (which is the root of the dependency tree).
Above the EnvironmentInjector is the PlatformInjector. It is created by the bootstrapApplication() and provides platform specific services like DOCUMENT and Location.
Finally, if the dependency can't be resolved anywhere in the dependency tree, the NullInjector is used. This injector will return null for any dependency it tries to resolve.
The NullInjector normally results in a NG0201 Error, which can be debugged by working backwards from the object, where the error is thrown.

Different kinds of providers

In bigger applications you might want to share classes and functions between all your building blocks. This can be achieved by using different kind of Providers. Sometimes you might want the same instance of a class (singleton) or you want to provide a specific implementation of a service (as a new instance). Other times you have more complex requirements and you need to provide a function to construct the final instance and sometimes you only want to provide a value.

Angular therefore provides a Provider interface, which can be used to configure the Injector to provide the required dependencies.

Basically there are four kinds of Providers:

Configure the Injector to ...

ClassProvider

Uses the abstract factory pattern to provide a constructed instance of a class or value.
Sometimes you want to provide a specific implementation of a service (as a new instance), but don't want to provide the service itself. This can be archived through an InjectionToken or abstract class combined with a useClass provider function. An example from angular directly would be the ViewportScroller (see code).
This follows the dependency inversion principle and helps decoupling the components from the implementation details:

Abstract Factory is a creational design pattern that lets you produce families of related objects without specifying their concrete classes.
From Refactoring Guru
  
import { Injectable } from '@angular/core';
import { inject } from '@angular/core';
import { ApplicationConfig } from '@angular/core';
import { environment } from '../environments/environment';

// This abstract class defines the "contract" for any logger.
// It cannot be instantiated directly.
export abstract class LoggerService {
    abstract log(message: string): void;
}

@Injectable()
export class ConsoleLoggerService extends LoggerService {
  log(message: string): void {
    console.log(`[Console]: ${message}`);
  }
}

@Injectable()
export class NullLoggerService extends LoggerService {
  log(message: string): void {
    // Does nothing!
  }
}

export const appConfig: ApplicationConfig = {
    providers: [
    {
      provide: LoggerService, // The Token (what is being requested)
      useClass: environment.production
        ? NullLoggerService
        : ConsoleLoggerService // The Class (what to instantiate)
    }
  ]
};
  

This can also be used to mock services in unit tests (although you should use spies) or abstract 3rd-Party libraries and much more.

ValueProvider

FactoryProvider

Uses the factory pattern to provide a constructed instance of a class or value.

ExistingProvider

Sometimes you have e.g. a service with a state in it and want to provide it as a singleton - the ExistingProvider comes into play.

The provide pattern

An often encountered problem is providing a components group's configuration (globally or locally). For this you can write a combination of an InjectionToken and a provider functions, which returns the EnvironmentProviders with the configuration as parameter:

This pattern is also mentioned in the Angular Docs

  
export interface IConfig {
  someConfig: string;
}

export const CONFIG = new InjectionToken<IConfig>('config');

export function provideConfig(config: IConfig): EnvironmentProviders {
    return makeEnvironmentProviders({
        providers: [
            {
                provide: CONFIG,
                useValue: config,
            }
        ]
    }
}
  

This config can now be injected:

  
@Injectable()
export class ConfigService {
  readonly config = inject(CONFIG);
}
  

Provided in root InjectionTokens

An InjectionToken that has a factory results in providedIn: 'root' by default (but can be overridden via the providedIn prop).

  
export const CONFIG = new InjectionToken<IConfig>('config', {
    providedIn: 'root',
    factory: () => ({
      someConfig: 'Some config string factory default'
  })
});
  

When you need dependency globally, but can't provide a class (e.g. when needing an injection context for providing this token), you can use a factory function.

  
export const COMPLEX_CONFIG = new InjectionToken<IConfig>('complex config', {
    providedIn: 'root',
    factory: () => {
      const config = inject(CONFIG);
      return {
        someConfig: config.someConfig + ' and some more complex stuff',
      };
  }
});
  

Advanced provide pattern

In angular packages you can observe configuration through a combination of provider functions, injection tokens and features. This is useful, when you want to provide a suite of configuration options, but don't know in advance which options are needed. The documentation mentions the provideHttpClient(...features: HttpFeature<HttpFeatureKind>[]) as an example.

The HttpFeature interface is a union type of all available features. So we define an enum with an entry for each feature and the HttpFeature interface for passing the feature kind and the providers for the feature::

    
export enum HttpFeatures {
    Interceptors = 'interceptors',
    Caching = 'caching',
    Retry = 'retry'
}

export interface HttpFeature {
    kind: HttpFeatures;
    providers: Provider[];
}
    
  
A union type is a type formed from two or more other types, representing values that may be any one of those types. We refer to each of these types as the union’s members.
From Typescript Docs

Next we define the Config interfaces for the individual features (if needed):

    
export interface HttpConfig {
      baseUrl?: string;
      timeout?: number;
      headers?: Record<string, string>
}
export interface RetryConfig {
      maxAttempts: number;
      delayMs: number;
}
  

Next we need an InjectionToken for each config and one for the HttpFeatures enum:

    
const HTTP_CONFIG = new InjectionToken<HttpConfig>('http.config');
const RETRY_CONFIG = new InjectionToken<RetryConfig>('retry.config');
const HTTP_FEATURES = new InjectionToken<Set<HttpFeatures>>('http.features');
  

Next we add some example implementations for the services using the different feature configs:

  
class HttpClientService {
      private config = inject(HTTP_CONFIG, { optional: true });
  private features = inject(HTTP_FEATURES);
  get(url: string) {
    // Use config and check features
  }
}
// Feature services
class RetryInterceptor {
      private config = inject(RETRY_CONFIG);
      // Retry logic
}
class CacheInterceptor {
      // Caching logic
}
  

Then we need with____ feature functions for every feature:

  
export function withInterceptors(...interceptors: any[]): HttpFeature {
    return {
      kind: HttpFeatures.Interceptors,
      providers: interceptors.map(interceptor => ({
        provide: INTERCEPTOR_TOKEN,
        useClass: interceptor,
        multi: true
      }))
    };
}
export function withCaching(): HttpFeature {
    return {
      kind: HttpFeatures.Caching,
      providers: [CacheInterceptor]
    };
}
export function withRetry(config: RetryConfig): HttpFeature {
    return {
      kind: HttpFeatures.Retry,
      providers: [
      { provide: RETRY_CONFIG, useValue: config },
        RetryInterceptor
      ]
    };
}
  

At last we add the provide pattern function, which utilises the provided features to return the correct providers:

  
export function provideHttpClient(
  config?: HttpConfig,
  ...features: HttpFeature[]
): Provider[] {
      const providers: Provider[] = [
        { provide: HTTP_CONFIG, useValue: config || {} },
        { provide: HTTP_FEATURES, useValue: new Set(features.map(f => f.kind)) },
        HttpClientService
      ];
  // Add feature-specific providers
  features.forEach(feature => {
    providers.push(...feature.providers);
  });
  return providers;
}
  

Then we can consume this provider function in our application config:

    
bootstrapApplication(AppComponent, {
      providers: [
        provideHttpClient(
          { baseUrl: 'https://api.example.com' },
          withInterceptors(AuthInterceptor, LoggingInterceptor),
          withCaching(),
          withRetry({ maxAttempts: 3, delayMs: 1000 })
)]});
    


This advanced pattern is also mentioned in the Angular Docs

Providing context scoped information

Sometimes you want to provide component scoped information, like an element's id. This can be archived through an InjectionToken combined with a useFactory provider function:

  
@Component({
  selector: 'app-card',
  providers: [
    {
      provide: CARD_ID,
      useFactory: (card: Card) => card.id,
      deps: [Card],
    },
  ],
  host: {
    '[attr.aria-labelledby]': 'id + "-header"',
    '[attr.aria-describedby]': 'id + "-content"',
    '[id]': 'id',
  },
})
export class Card {
  static nextId = 0;
  readonly id = `app-card-${Card.nextId++}`;
}
  

This injection token with the individual card id you can now use in child components to construct e.g. the aria-labelledby host id:

  
@Component({
  selector: 'app-card-header',
  host: {
    '[id]': 'id + "-header"'
  },
})
export class CardHeader {
    readonly id = inject(CARD_ID);
}