Skip to main content

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:

ValueDescription
'root'App-wide singleton (most common)
'platform'Shared across all apps on the same platform
'any'New instance per module
Component classExclusive 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
// 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.