Skip to main content
Advertisement

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.

Advertisement