Angular — Advanced Dependency Injection Techniques
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 inject
function, 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.