Angular — Advanced Dependency Injection Techniques

Babatunde Lamidi
3 min readJul 11, 2024

--

Angular 18 has brought a plethora of new features and enhancements, making it a robust framework for building complex applications. One of the core features of Angular that stands out is Dependency Injection (DI). While many developers are familiar with the basics of DI, there are advanced techniques that can significantly enhance your application’s architecture and performance.

In this article, we’ll delve into some advanced DI techniques in Angular 18, including the new injectfunction, hierarchical injectors, InjectionToken, factory providers, and custom injectors.

What is Dependency Injection?

Dependency Injection is a design pattern used to implement IoC (Inversion of Control), allowing a class to receive its dependencies from an external source rather than creating them itself. This pattern promotes better modularity, easier testing, and cleaner code.

Hierarchical Injectors

Angular uses a hierarchical injector system, meaning there is a tree of injectors that parallels the component tree. This allows for fine-grained control over the scope of services.


@Injectable({
providedIn: 'root'
})
export class RootService {
constructor() { }
}
@Component({
selector: 'app-parent',
template: '<app-child></app-child>',
providers: [ParentService]
})
export class ParentComponent {
constructor(private parentService: ParentService) { }
}
@Component({
selector: 'app-child',
template: '<p>Child works!</p>'
})
export class ChildComponent {
constructor(private parentService: ParentService) { }
}

In this example, ParentService is scoped to ParentComponent and its children. ChildComponent receives the same instance of ParentService as ParentComponent.

InjectionToken

Using strings as injection tokens can lead to naming collisions. InjectionToken is a robust way to avoid this.

export const API_URL = new InjectionToken<string>('apiUrl');

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

export class AppModule { }
@Component({
selector: 'app-root',
template: `<h1>Angular 18 DI</h1>`
})
export class AppComponent {
constructor(@Inject(API_URL) private apiUrl: string) {
console.log('API URL:', this.apiUrl);
}
}

This approach ensures that the token API_URL is unique and avoids potential naming conflicts.

Factory Providers

Sometimes you need to create a service instance with some complex logic. Factory providers come in handy for such scenarios.

@Injectable()
export class ConfigService {
constructor(private http: HttpClient) { }

loadConfig() {
return this.http.get('/config');
}
}
export function configServiceFactory(http: HttpClient) {
const service = new ConfigService(http);
service.loadConfig();
return service;
}
@NgModule({
providers: [
{ provide: ConfigService, useFactory: configServiceFactory, deps: [HttpClient] }
]
})
export class AppModule { }

In this example, ConfigService is created using a factory function configServiceFactory, allowing for any custom initialization logic to be executed.

Custom Injectors

Creating custom injectors can give you complete control over how dependencies are resolved.


const customInjector = Injector.create({
providers: [
{ provide: Logger, useClass: CustomLogger }
]
});
const logger = customInjector.get(Logger);
logger.log('Custom injector works!');

Custom injectors are particularly useful in scenarios where you need to dynamically create instances of services with specific configurations.

The inject Function

Angular 18 introduces the inject function, a new API that allows you to inject dependencies directly within the class body, without the need to use the constructor for injection. This can lead to cleaner and more readable code, especially in scenarios where you have multiple dependencies or where dependency injection is conditional.

import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-example',
template: `<h1>Example Component</h1>`
})
export class ExampleComponent {
private httpClient = inject(HttpClient);
constructor() {
this.httpClient.get('/api/data').subscribe(data => {
console.log(data);
});
}
}

Conditional Injection

import { Component, inject, Optional } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { LoggerService } from './logger.service';
@Component({
selector: 'app-example',
template: `<h1>Example Component</h1>`
})
export class ExampleComponent {
private httpClient = inject(HttpClient);
private loggerService = inject(LoggerService, { optional: true });
constructor() {
this.httpClient.get('/api/data').subscribe(data => {
if (this.loggerService) {
this.loggerService.log('Data received', data);
} else {
console.log('Data received', data);
}
});
}
}

Using inject in Services

import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { AuthService } from './auth.service';
@Injectable({
providedIn: 'root'
})
export class DataService {
private httpClient = inject(HttpClient);
private authService = inject(AuthService);
fetchData() {
const token = this.authService.getToken();
return this.httpClient.get('/api/data', {
headers: {
Authorization: `Bearer ${token}`
}
});
}
}

The inject function significantly enhances dependency injection. It provides a more flexible and readable approach to injecting dependencies, leading to cleaner code and improved testability.

--

--

Babatunde Lamidi

Frontend Engineer lost in the matrix of Frontend engineering and Poetry