16.4 Dependency Injection (DI) — Services, @Injectable, Providers, Hierarchical DI
What Is Dependency Injection?
Dependency Injection (DI) is a design pattern where an object receives its required dependencies from the outside rather than creating them itself.
// Without DI — Tight Coupling
class OrderComponent {
// Creates directly → hard to test, many changes needed when modified
private userService = new UserService();
private orderService = new OrderService(new HttpClient());
}
// With DI — Loose Coupling
class OrderComponent {
// Angular injects automatically — easy to test, easy to replace
constructor(
private userService: UserService,
private orderService: OrderService
) {}
}
Benefits of Angular's DI system:
- Reusability: Share services across multiple components
- Testability: Easily replaced with mock services
- Maintainability: Dependency changes affect only one place
@Injectable Decorator
Use the @Injectable() decorator to register a service with the DI system.
// services/counter.service.ts
import { Injectable, signal, computed } from '@angular/core';
@Injectable({
providedIn: 'root' // Available as a singleton throughout the app
})
export class CounterService {
private count = signal(0);
// Read-only computed
readonly currentCount = computed(() => this.count());
readonly isPositive = computed(() => this.count() > 0);
increment() {
this.count.update(n => n + 1);
}
decrement() {
this.count.update(n => n - 1);
}
reset() {
this.count.set(0);
}
}
providedIn options:
| Value | Description |
|---|---|
'root' | App-wide singleton (most common) |
'platform' | Shared across all apps on the same platform |
'any' | New instance per module |
| Component class | Exclusive to that component and its children |
Creating and Injecting Services
Generate with Angular CLI
ng generate service services/user
# or
ng g s services/user
inject() Function (Angular 14+ Recommended)
// services/user.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, signal } from 'rxjs';
export interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
}
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
private apiUrl = 'https://jsonplaceholder.typicode.com/users';
// Cache state
private cachedUsers = signal<User[]>([]);
getUsers(): Observable<User[]> {
return this.http.get<User[]>(this.apiUrl);
}
getUserById(id: number): Observable<User> {
return this.http.get<User>(`${this.apiUrl}/${id}`);
}
createUser(user: Omit<User, 'id'>): Observable<User> {
return this.http.post<User>(this.apiUrl, user);
}
updateUser(id: number, user: Partial<User>): Observable<User> {
return this.http.patch<User>(`${this.apiUrl}/${id}`, user);
}
deleteUser(id: number): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
}
// components/user-list.component.ts
import { Component, OnInit, inject, signal } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { UserService, User } from '../services/user.service';
@Component({
selector: 'app-user-list',
standalone: true,
imports: [AsyncPipe],
template: `
<h2>User List</h2>
@if (isLoading()) {
<p>Loading...</p>
} @else if (error()) {
<p class="error">Error: {{ error() }}</p>
} @else {
<ul>
@for (user of users(); track user.id) {
<li>{{ user.name }} ({{ user.email }})</li>
}
</ul>
}
`
})
export class UserListComponent implements OnInit {
// Inject service with inject() function
private userService = inject(UserService);
users = signal<User[]>([]);
isLoading = signal(false);
error = signal<string | null>(null);
ngOnInit() {
this.loadUsers();
}
private loadUsers() {
this.isLoading.set(true);
this.error.set(null);
this.userService.getUsers().subscribe({
next: (users) => {
this.users.set(users);
this.isLoading.set(false);
},
error: (err) => {
this.error.set(err.message);
this.isLoading.set(false);
}
});
}
}
providedIn: 'root' vs Component-Level DI
Root Level (Global Singleton)
@Injectable({ providedIn: 'root' })
export class GlobalStateService {
// One instance across the entire app
// All components share the same state
}
Component Level (Local Instance)
@Component({
selector: 'app-cart',
standalone: true,
// Register in providers — only this component and its children use it
providers: [CartService],
template: `...`
})
export class CartComponent {
// Independent instance of CartService
private cartService = inject(CartService);
}
// CartService is @Injectable but has no providedIn
@Injectable() // No providedIn — provided by component's providers
export class CartService {
private items = signal<CartItem[]>([]);
// Each CartComponent gets its own independent instance
}
Hierarchical DI System
Angular's DI forms a hierarchical injector tree:
EnvironmentInjector (root)
└── App-wide services (providedIn: 'root')
└── Component Injectors
├── AppComponent
│ ├── HeaderComponent
│ └── MainComponent
│ ├── CartComponent (providers: [CartService])
│ │ └── CartItemComponent ← Can access CartService
│ └── ProductListComponent ← Cannot access CartService
// Override a parent service in a child (Override)
@Component({
selector: 'app-theme-override',
standalone: true,
providers: [
{
provide: ThemeService,
useClass: DarkThemeService // Use DarkThemeService instead of default ThemeService
}
],
template: `...`
})
export class ThemeOverrideComponent {}
Various Provider Patterns
useClass — Replace with a Different Class
// Define interface token
import { InjectionToken } from '@angular/core';
export interface Logger {
log(message: string): void;
error(message: string): void;
}
export const LOGGER = new InjectionToken<Logger>('Logger');
// Implementations
@Injectable()
export class ConsoleLogger implements Logger {
log(message: string) { console.log(`[LOG] ${message}`); }
error(message: string) { console.error(`[ERROR] ${message}`); }
}
@Injectable()
export class RemoteLogger implements Logger {
log(message: string) { /* Send to server */ }
error(message: string) { /* Send to server */ }
}
// Switch by environment in app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
{
provide: LOGGER,
useClass: environment.production ? RemoteLogger : ConsoleLogger
}
]
};
// Usage
@Injectable({ providedIn: 'root' })
export class SomeService {
private logger = inject(LOGGER);
doSomething() {
this.logger.log('Performing action');
}
}
useValue — Inject Constant Values
export const API_CONFIG = new InjectionToken<ApiConfig>('ApiConfig');
export const appConfig: ApplicationConfig = {
providers: [
{
provide: API_CONFIG,
useValue: {
baseUrl: 'https://api.example.com',
timeout: 5000,
version: 'v2'
}
}
]
};
// Usage
@Injectable({ providedIn: 'root' })
export class ApiService {
private config = inject(API_CONFIG);
// Access this.config.baseUrl, this.config.timeout
}
useFactory — Dynamic Creation
export const appConfig: ApplicationConfig = {
providers: [
{
provide: LOGGER,
useFactory: (http: HttpClient) => {
if (environment.production) {
return new RemoteLogger(http);
}
return new ConsoleLogger();
},
deps: [HttpClient] // Dependencies to inject into factory function
}
]
};
useExisting — Set Alias
// Allow NewApiService to also be injected via ApiService token
{
provide: ApiService,
useExisting: NewApiService
}
Real-World Example — API Service with HttpClient
// services/product.service.ts
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, catchError, tap, throwError } from 'rxjs';
export interface Product {
id: number;
title: string;
price: number;
category: string;
description: string;
image: string;
rating: { rate: number; count: number };
}
@Injectable({ providedIn: 'root' })
export class ProductService {
private http = inject(HttpClient);
private readonly baseUrl = 'https://fakestoreapi.com/products';
// Local cache
private _products = signal<Product[]>([]);
private _isLoading = signal(false);
private _error = signal<string | null>(null);
// Public read-only state
readonly products = this._products.asReadonly();
readonly isLoading = this._isLoading.asReadonly();
readonly error = this._error.asReadonly();
// Derived state
readonly categories = computed(() =>
[...new Set(this._products().map(p => p.category))]
);
// Load all products
loadProducts(): void {
this._isLoading.set(true);
this._error.set(null);
this.http.get<Product[]>(this.baseUrl).pipe(
tap(products => {
this._products.set(products);
this._isLoading.set(false);
}),
catchError(err => {
this._error.set('Failed to load products.');
this._isLoading.set(false);
return throwError(() => err);
})
).subscribe();
}
// Get single product
getProduct(id: number): Observable<Product> {
return this.http.get<Product>(`${this.baseUrl}/${id}`);
}
// Get by category
getByCategory(category: string): Observable<Product[]> {
return this.http.get<Product[]>(`${this.baseUrl}/category/${category}`);
}
// Create product
createProduct(product: Omit<Product, 'id'>): Observable<Product> {
return this.http.post<Product>(this.baseUrl, product).pipe(
tap(newProduct => {
this._products.update(products => [...products, newProduct]);
})
);
}
// Update product
updateProduct(id: number, updates: Partial<Product>): Observable<Product> {
return this.http.put<Product>(`${this.baseUrl}/${id}`, updates).pipe(
tap(updated => {
this._products.update(products =>
products.map(p => p.id === id ? updated : p)
);
})
);
}
// Delete product
deleteProduct(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`).pipe(
tap(() => {
this._products.update(products =>
products.filter(p => p.id !== id)
);
})
);
}
}
Unit Testing Services
// services/product.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { ProductService } from './product.service';
describe('ProductService', () => {
let service: ProductService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [ProductService]
});
service = TestBed.inject(ProductService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // Verify no unhandled requests
});
it('should load products', () => {
const mockProducts = [
{ id: 1, title: 'Test Product', price: 10, category: 'test', description: '', image: '', rating: { rate: 4, count: 100 } }
];
service.loadProducts();
const req = httpMock.expectOne('https://fakestoreapi.com/products');
expect(req.request.method).toBe('GET');
req.flush(mockProducts);
expect(service.products()).toEqual(mockProducts);
expect(service.isLoading()).toBe(false);
});
it('should set error state on failure', () => {
service.loadProducts();
const req = httpMock.expectOne('https://fakestoreapi.com/products');
req.flush('Server Error', { status: 500, statusText: 'Internal Server Error' });
expect(service.error()).toBeTruthy();
expect(service.isLoading()).toBe(false);
});
});
Pro Tips
Tip 1: inject() vs Constructor Injection
Angular 14+ recommends the inject() function approach:
// Old approach (constructor)
export class MyComponent {
constructor(
private userService: UserService,
private router: Router
) {}
}
// New approach (inject function) — more concise and functional-programming friendly
export class MyComponent {
private userService = inject(UserService);
private router = inject(Router);
}
Tip 2: Type-Safe Config Injection with InjectionToken
Inject environment variables and configuration objects in a type-safe manner.
Tip 3: Service File Location Convention
src/app/
├── core/ ← Global services, guards, interceptors
│ └── services/
│ ├── auth.service.ts
│ └── logger.service.ts
└── features/
└── products/
├── product.service.ts ← Feature-specific service
└── product-list.component.ts
Tip 4: Preventing Circular Dependencies
If service A injects service B and B also injects A, a circular dependency error occurs. Extract shared state into a separate service to resolve this.