Angular Dependency Injection
Introduction
Dependency Injection (DI) is one of Angular's most powerful features and a core concept that makes Angular applications more maintainable, testable, and scalable. At its core, DI is a design pattern that allows a class to receive its dependencies from external sources rather than creating them itself.
In simpler terms, instead of a class creating its own service objects, Angular's DI system provides these objects (dependencies) to the class when it's created. This decouples your components from specific implementations of services, making your code more modular and easier to test.
Why Dependency Injection Matters
Before diving into how Angular implements DI, let's understand why it's important:
- Decoupling: Components aren't responsible for creating their dependencies, making code more modular
- Testability: Dependencies can be easily mocked during testing
- Maintainability: Services can be improved without changing the components that use them
- Reusability: The same service can be injected into multiple components
The Core Components of Angular's DI System
Angular's dependency injection system consists of three main players:
- The Consumer - The class that needs the dependency (typically a component or another service)
- The Dependency - The service or value being provided
- The Injector - Angular's mechanism for creating and providing dependencies
Let's explore each through practical examples.
Creating an Injectable Service
First, let's create a simple service that we'll inject into a component:
// data.service.ts
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class DataService {
private data: string[] = ['Apple', 'Banana', 'Orange'];
getData(): string[] {
return this.data;
}
addData(item: string): void {
this.data.push(item);
}
}
The @Injectable()
decorator marks this class as available for dependency injection. The providedIn: 'root'
option makes this service a singleton available throughout the application.
Injecting a Service into a Component
Now, let's inject this service into a component:
// fruit-list.component.ts
import { Component, OnInit } from '@angular/core';
import { DataService } from './data.service';
@Component({
selector: 'app-fruit-list',
template: `
<h2>Fruit List</h2>
<ul>
<li *ngFor="let fruit of fruits">{{ fruit }}</li>
</ul>
<input #newFruit placeholder="Add fruit">
<button (click)="addFruit(newFruit.value); newFruit.value=''">Add</button>
`
})
export class FruitListComponent implements OnInit {
fruits: string[] = [];
// The DataService is injected via the constructor
constructor(private dataService: DataService) { }
ngOnInit(): void {
// Get data from the service
this.fruits = this.dataService.getData();
}
addFruit(fruit: string): void {
if (fruit.trim()) {
this.dataService.addData(fruit);
}
}
}
In this example, Angular's DI system:
- Recognizes that
FruitListComponent
needs aDataService
- Checks if an instance of
DataService
already exists (it's a singleton) - Creates one if it doesn't exist
- Provides that instance to the component's constructor
Understanding Injection Hierarchies
Angular's DI system operates within a hierarchy that follows the component tree. This means services can be provided at different levels:
- Application-wide (
providedIn: 'root'
): One instance shared by all components - Module level (provided in a module's
providers
array) - Component level (provided in a component's
providers
array): Creates a new instance for this component and its children
Let's see how component-level injection works:
// counter.service.ts
import { Injectable } from '@angular/core';
@Injectable()
export class CounterService {
private count = 0;
increment(): void {
this.count++;
}
getCount(): number {
return this.count;
}
}
// parent.component.ts
import { Component } from '@angular/core';
import { CounterService } from './counter.service';
@Component({
selector: 'app-parent',
template: `
<h2>Parent Counter: {{ getCount() }}</h2>
<button (click)="increment()">Increment</button>
<app-child></app-child>
<app-child></app-child>
`,
providers: [CounterService] // Service provided at component level
})
export class ParentComponent {
constructor(private counterService: CounterService) { }
increment(): void {
this.counterService.increment();
}
getCount(): number {
return this.counterService.getCount();
}
}
// child.component.ts
import { Component } from '@angular/core';
import { CounterService } from './counter.service';
@Component({
selector: 'app-child',
template: `
<div style="margin-left: 20px; border: 1px solid #ccc; padding: 10px;">
<h3>Child Counter: {{ getCount() }}</h3>
<button (click)="increment()">Increment</button>
</div>
`
})
export class ChildComponent {
constructor(private counterService: CounterService) { }
increment(): void {
this.counterService.increment();
}
getCount(): number {
return this.counterService.getCount();
}
}
In this example:
- The
ParentComponent
providesCounterService
in its providers array - Both
ChildComponent
instances share the sameCounterService
instance as their parent - If we clicked any increment button (parent or child), all counters would update because they share the same service instance
If we wanted each ChildComponent
to have its own counter, we would add providers: [CounterService]
to the child component as well.
Using Interface Injection Tokens
Sometimes we want to inject a service that implements a specific interface rather than a concrete class. Since TypeScript interfaces don't exist at runtime, Angular provides the InjectionToken
class:
// logger.interface.ts
export interface Logger {
log(message: string): void;
error(message: string): void;
}
// console-logger.service.ts
import { Injectable } from '@angular/core';
import { Logger } from './logger.interface';
@Injectable()
export class ConsoleLoggerService implements Logger {
log(message: string): void {
console.log(`LOG: ${message}`);
}
error(message: string): void {
console.error(`ERROR: ${message}`);
}
}
// app.module.ts
import { InjectionToken, NgModule } from '@angular/core';
import { ConsoleLoggerService } from './console-logger.service';
import { Logger } from './logger.interface';
export const LOGGER = new InjectionToken<Logger>('Logger');
@NgModule({
// ...
providers: [
{ provide: LOGGER, useClass: ConsoleLoggerService }
]
})
export class AppModule { }
// some.component.ts
import { Component, Inject } from '@angular/core';
import { LOGGER } from '../app.module';
import { Logger } from './logger.interface';
@Component({
selector: 'app-some',
template: '<button (click)="doSomething()">Log Something</button>'
})
export class SomeComponent {
constructor(@Inject(LOGGER) private logger: Logger) { }
doSomething(): void {
this.logger.log('Button clicked!');
}
}
This pattern allows you to easily swap implementations (for example, replacing the console logger with one that sends logs to a server) without changing the components that use the logger.
Advanced DI Features
Value Providers
You can provide literal values instead of service instances:
// app.module.ts
import { NgModule } from '@angular/core';
export const API_URL = new InjectionToken<string>('API_URL');
@NgModule({
providers: [
{ provide: API_URL, useValue: 'https://api.example.com/v1' }
]
})
export class AppModule { }
// data.service.ts
import { Inject, Injectable } from '@angular/core';
import { API_URL } from './app.module';
@Injectable({
providedIn: 'root'
})
export class DataService {
constructor(@Inject(API_URL) private apiUrl: string) {
console.log(`Using API URL: ${this.apiUrl}`);
}
}
Factory Providers
Factory providers allow you to create dependencies dynamically:
// app.module.ts
import { NgModule } from '@angular/core';
import { LoggerService } from './logger.service';
export function loggerFactory() {
// Could include logic to determine which logger to use
const isProd = window.location.hostname !== 'localhost';
if (isProd) {
return new LoggerService(true); // Production logger
} else {
return new LoggerService(false); // Development logger
}
}
@NgModule({
providers: [
{ provide: LoggerService, useFactory: loggerFactory }
]
})
export class AppModule { }
Optional Dependencies
Sometimes a service might need a dependency that isn't always available. You can mark it as optional:
import { Injectable, Optional } from '@angular/core';
import { ConfigService } from './config.service';
@Injectable({
providedIn: 'root'
})
export class FeatureService {
constructor(@Optional() private config: ConfigService) {
if (config) {
// Use config if available
} else {
// Use default settings
}
}
}
Real-World Example: Authentication Service
Let's implement a practical authentication service that can be injected throughout an application:
// auth.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { tap } from 'rxjs/operators';
interface User {
id: number;
username: string;
token: string;
}
@Injectable({
providedIn: 'root'
})
export class AuthService {
private currentUserSubject = new BehaviorSubject<User | null>(null);
public currentUser$: Observable<User | null> = this.currentUserSubject.asObservable();
constructor(private http: HttpClient) {
// Check if we have a user in local storage on initialization
const savedUser = localStorage.getItem('currentUser');
if (savedUser) {
this.currentUserSubject.next(JSON.parse(savedUser));
}
}
login(username: string, password: string): Observable<User> {
return this.http.post<User>('/api/login', { username, password }).pipe(
tap(user => {
// Store user details and token in local storage
localStorage.setItem('currentUser', JSON.stringify(user));
this.currentUserSubject.next(user);
})
);
}
logout(): void {
// Remove user from local storage and reset the subject
localStorage.removeItem('currentUser');
this.currentUserSubject.next(null);
}
getCurrentUser(): User | null {
return this.currentUserSubject.value;
}
isAuthenticated(): boolean {
return this.currentUserSubject.value !== null;
}
}
Now let's inject this service into components that need authentication:
// login.component.ts
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from './auth.service';
@Component({
selector: 'app-login',
template: `
<form (ngSubmit)="onSubmit()">
<div>
<label for="username">Username:</label>
<input type="text" id="username" [(ngModel)]="username" name="username" required>
</div>
<div>
<label for="password">Password:</label>
<input type="password" id="password" [(ngModel)]="password" name="password" required>
</div>
<button type="submit">Login</button>
<p *ngIf="error">{{ error }}</p>
</form>
`
})
export class LoginComponent {
username = '';
password = '';
error = '';
constructor(
private authService: AuthService,
private router: Router
) { }
onSubmit(): void {
this.error = '';
this.authService.login(this.username, this.password).subscribe({
next: () => {
this.router.navigate(['/dashboard']);
},
error: err => {
this.error = 'Failed to login. Please check your credentials.';
console.error('Login error:', err);
}
});
}
}
// auth.guard.ts
import { Injectable } from '@angular/core';
import { CanActivate, Router } from '@angular/router';
import { AuthService } from './auth.service';
@Injectable({
providedIn: 'root'
})
export class AuthGuard implements CanActivate {
constructor(
private authService: AuthService,
private router: Router
) { }
canActivate(): boolean {
if (this.authService.isAuthenticated()) {
return true;
}
// User is not authenticated, redirect to login page
this.router.navigate(['/login']);
return false;
}
}
This real-world example shows how dependency injection enables:
- Sharing authentication state throughout the application
- Protecting routes with guards that use the authentication service
- Making login functionality available where needed
Summary
Angular's dependency injection system is a powerful feature that:
- Simplifies component code by delegating service creation to Angular
- Provides a hierarchy system that allows for different service instances at different levels
- Makes applications more testable by easily swapping dependencies
- Facilitates the creation of modular, reusable services that can be shared across components
By understanding and leveraging DI effectively, you can create more maintainable, testable, and scalable Angular applications. Remember that the core principle is to have your classes receive dependencies rather than creating them.
Additional Resources
Exercises
-
Create a simple service with a method that returns an array of items, and inject it into a component that displays these items.
-
Create two services where one service depends on the other, and inject the first service into a component.
-
Try creating a service with different provider configurations (root, component, and module level) and observe how each affects the service's behavior.
-
Implement a shopping cart service that maintains state across different components and persists the cart contents in local storage.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)