Module Overview
Estimated Time: 3-4 hours | Difficulty: Intermediate | Prerequisites: Module 3
- Creating injectable services
- Understanding the injector hierarchy
- Provider configurations
- The modern inject() function
- InjectionTokens for configuration
- Multi-providers and use cases
What is Dependency Injection?
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ Without DI vs With DI │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ WITHOUT DI (Tightly Coupled) │
│ ───────────────────────────── │
│ class UserComponent { │
│ private userService = new UserService(); // Hard to test! │
│ private http = new HttpClient(); // Creates own deps │
│ } │
│ │
│ WITH DI (Loosely Coupled) │
│ ─────────────────────────── │
│ class UserComponent { │
│ private userService = inject(UserService); // DI provides it │
│ } │
│ │
│ Benefits: │
│ ✓ Easier to test (mock dependencies) │
│ ✓ Loosely coupled code │
│ ✓ Single instance (singleton) management │
│ ✓ Flexible configuration │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Creating a Service
Basic Service
Copy
// user.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface User {
id: number;
name: string;
email: string;
}
@Injectable({
providedIn: 'root' // Singleton, available everywhere
})
export class UserService {
private http = inject(HttpClient);
private apiUrl = '/api/users';
getUsers(): Observable<User[]> {
return this.http.get<User[]>(this.apiUrl);
}
getUser(id: number): Observable<User> {
return this.http.get<User>(`${this.apiUrl}/${id}`);
}
createUser(user: Partial<User>): Observable<User> {
return this.http.post<User>(this.apiUrl, user);
}
updateUser(id: number, user: Partial<User>): Observable<User> {
return this.http.put<User>(`${this.apiUrl}/${id}`, user);
}
deleteUser(id: number): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
}
Using the Service
Copy
// users.component.ts
import { Component, inject, OnInit } from '@angular/core';
import { UserService, User } from './user.service';
import { AsyncPipe } from '@angular/common';
@Component({
selector: 'app-users',
standalone: true,
imports: [AsyncPipe],
template: `
@if (users$ | async; as users) {
<ul>
@for (user of users; track user.id) {
<li>{{ user.name }} - {{ user.email }}</li>
}
</ul>
} @else {
<p>Loading...</p>
}
`
})
export class UsersComponent implements OnInit {
// Modern inject() function
private userService = inject(UserService);
users$!: Observable<User[]>;
ngOnInit() {
this.users$ = this.userService.getUsers();
}
}
inject() vs Constructor Injection
Angular provides two ways to inject dependencies:Modern: inject() Function
Copy
import { Component, inject } from '@angular/core';
@Component({...})
export class UserComponent {
// Inject in field declaration
private userService = inject(UserService);
private router = inject(Router);
private route = inject(ActivatedRoute);
// Can also inject in constructor body
constructor() {
const http = inject(HttpClient); // Valid here too
}
}
Classic: Constructor Injection
Copy
import { Component } from '@angular/core';
@Component({...})
export class UserComponent {
constructor(
private userService: UserService,
private router: Router,
private route: ActivatedRoute
) {}
}
Recommendation: Use
inject() for new code. It’s more concise, works with functional features like guards and resolvers, and doesn’t require decorator metadata for TypeScript.Injector Hierarchy
Angular has a hierarchical injector system:Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ Injector Hierarchy │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ │
│ │ Root Injector │ │
│ │ (Application) │ │
│ │ providedIn: root│ │
│ └────────┬────────┘ │
│ │ │
│ ┌─────────────────┼─────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ Module Injector│ │ Module Injector│ │ Module Injector│ │
│ │ (Feature A) │ │ (Feature B) │ │ (Feature C) │ │
│ └───────┬────────┘ └────────────────┘ └────────────────┘ │
│ │ │
│ ┌───────┼───────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │Comp │ │Comp │ │Comp │ ← Component injectors │
│ │ A │ │ B │ │ C │ (providers: [...]) │
│ └─────┘ └─────┘ └─────┘ │
│ │
│ Resolution Order: Component → Module → Root │
│ (Bubble up until found) │
│ │
└─────────────────────────────────────────────────────────────────────────┘
providedIn Options
Copy
// Application-wide singleton (recommended)
@Injectable({ providedIn: 'root' })
export class GlobalService {}
// Platform-wide (multiple apps)
@Injectable({ providedIn: 'platform' })
export class PlatformService {}
// No automatic provision (must provide manually)
@Injectable()
export class ManualService {}
Provider Configurations
Providing in Component
Copy
@Component({
selector: 'app-feature',
standalone: true,
providers: [
FeatureService // New instance for this component tree
],
template: `...`
})
export class FeatureComponent {
private featureService = inject(FeatureService);
}
useClass - Alternative Implementation
Copy
// Interface/abstract class
export abstract class Logger {
abstract log(message: string): void;
}
// Real implementation
@Injectable()
export class ConsoleLogger implements Logger {
log(message: string) {
console.log(`[LOG] ${message}`);
}
}
// Mock for testing
@Injectable()
export class MockLogger implements Logger {
log(message: string) {
// Do nothing
}
}
// Provide alternative implementation
@Component({
providers: [
{ provide: Logger, useClass: ConsoleLogger }
]
})
useValue - Static Values
Copy
// Provide a static value
@Component({
providers: [
{ provide: 'API_URL', useValue: 'https://api.example.com' }
]
})
export class AppComponent {
private apiUrl = inject<string>('API_URL');
}
useFactory - Dynamic Creation
Copy
// Factory function for complex initialization
export function loggerFactory(config: AppConfig): Logger {
if (config.production) {
return new CloudLogger();
}
return new ConsoleLogger();
}
@Component({
providers: [
{
provide: Logger,
useFactory: loggerFactory,
deps: [AppConfig] // Dependencies for factory
}
]
})
useExisting - Alias
Copy
// Create an alias for an existing service
@Component({
providers: [
NewLogger,
{ provide: OldLogger, useExisting: NewLogger }
]
})
InjectionToken
For non-class dependencies, useInjectionToken:
Copy
// tokens.ts
import { InjectionToken } from '@angular/core';
export interface AppConfig {
apiUrl: string;
production: boolean;
features: {
darkMode: boolean;
analytics: boolean;
};
}
export const APP_CONFIG = new InjectionToken<AppConfig>('app.config');
// Default value with factory
export const API_BASE_URL = new InjectionToken<string>('api.base.url', {
providedIn: 'root',
factory: () => 'https://api.example.com'
});
Copy
// app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
{
provide: APP_CONFIG,
useValue: {
apiUrl: 'https://api.example.com',
production: true,
features: {
darkMode: true,
analytics: true
}
}
}
]
};
Copy
// Using the token
@Component({...})
export class HeaderComponent {
private config = inject(APP_CONFIG);
get showDarkMode() {
return this.config.features.darkMode;
}
}
Multi-Providers
Provide multiple values for the same token:Copy
// HTTP Interceptors (common use case)
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([
authInterceptor,
loggingInterceptor,
errorInterceptor
])
)
]
};
// Custom multi-provider
export const VALIDATORS = new InjectionToken<Validator[]>('validators');
@Component({
providers: [
{ provide: VALIDATORS, useClass: RequiredValidator, multi: true },
{ provide: VALIDATORS, useClass: EmailValidator, multi: true },
{ provide: VALIDATORS, useClass: MinLengthValidator, multi: true }
]
})
export class FormComponent {
private validators = inject(VALIDATORS); // Array of validators
}
Service Design Patterns
State Service with Signals
Copy
// state.service.ts
import { Injectable, signal, computed } from '@angular/core';
export interface AppState {
user: User | null;
theme: 'light' | 'dark';
notifications: Notification[];
}
@Injectable({ providedIn: 'root' })
export class StateService {
// Private writable signals
private _user = signal<User | null>(null);
private _theme = signal<'light' | 'dark'>('light');
private _notifications = signal<Notification[]>([]);
// Public read-only signals
readonly user = this._user.asReadonly();
readonly theme = this._theme.asReadonly();
readonly notifications = this._notifications.asReadonly();
// Computed values
readonly isLoggedIn = computed(() => this._user() !== null);
readonly unreadCount = computed(() =>
this._notifications().filter(n => !n.read).length
);
// Actions
setUser(user: User | null) {
this._user.set(user);
}
toggleTheme() {
this._theme.update(t => t === 'light' ? 'dark' : 'light');
}
addNotification(notification: Notification) {
this._notifications.update(n => [...n, notification]);
}
markAsRead(id: string) {
this._notifications.update(notifications =>
notifications.map(n =>
n.id === id ? { ...n, read: true } : n
)
);
}
}
Repository Pattern
Copy
// base-repository.ts
export abstract class BaseRepository<T extends { id: number }> {
protected abstract endpoint: string;
protected http = inject(HttpClient);
getAll(): Observable<T[]> {
return this.http.get<T[]>(this.endpoint);
}
getById(id: number): Observable<T> {
return this.http.get<T>(`${this.endpoint}/${id}`);
}
create(item: Partial<T>): Observable<T> {
return this.http.post<T>(this.endpoint, item);
}
update(id: number, item: Partial<T>): Observable<T> {
return this.http.put<T>(`${this.endpoint}/${id}`, item);
}
delete(id: number): Observable<void> {
return this.http.delete<void>(`${this.endpoint}/${id}`);
}
}
// user-repository.ts
@Injectable({ providedIn: 'root' })
export class UserRepository extends BaseRepository<User> {
protected endpoint = '/api/users';
// Add user-specific methods
getByEmail(email: string): Observable<User> {
return this.http.get<User>(`${this.endpoint}/email/${email}`);
}
}
Facade Pattern
Copy
// user-facade.service.ts
@Injectable({ providedIn: 'root' })
export class UserFacade {
private userService = inject(UserService);
private authService = inject(AuthService);
private notificationService = inject(NotificationService);
// Expose state
readonly currentUser = this.authService.currentUser;
readonly isLoading = signal(false);
// Unified interface for complex operations
async login(email: string, password: string) {
this.isLoading.set(true);
try {
const user = await firstValueFrom(
this.authService.login(email, password)
);
this.notificationService.show('Welcome back!');
return user;
} catch (error) {
this.notificationService.showError('Login failed');
throw error;
} finally {
this.isLoading.set(false);
}
}
async updateProfile(updates: Partial<User>) {
const currentUser = this.currentUser();
if (!currentUser) throw new Error('Not logged in');
const updated = await firstValueFrom(
this.userService.updateUser(currentUser.id, updates)
);
this.authService.setUser(updated);
this.notificationService.show('Profile updated');
return updated;
}
}
Testing Services
Copy
// user.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { UserService, User } from './user.service';
describe('UserService', () => {
let service: UserService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [UserService]
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // Ensure no outstanding requests
});
it('should fetch users', () => {
const mockUsers: User[] = [
{ id: 1, name: 'John', email: '[email protected]' }
];
service.getUsers().subscribe(users => {
expect(users).toEqual(mockUsers);
});
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('GET');
req.flush(mockUsers);
});
it('should create a user', () => {
const newUser = { name: 'Jane', email: '[email protected]' };
const createdUser = { id: 2, ...newUser };
service.createUser(newUser).subscribe(user => {
expect(user).toEqual(createdUser);
});
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(newUser);
req.flush(createdUser);
});
});
Practice Exercise
Exercise: Build a Shopping Cart Service
Create a
CartService that:- Uses signals for reactive state
- Supports add, remove, update quantity
- Computes total price and item count
- Persists to localStorage
Solution
Solution
Copy
// cart.service.ts
interface CartItem {
productId: number;
name: string;
price: number;
quantity: number;
}
@Injectable({ providedIn: 'root' })
export class CartService {
private _items = signal<CartItem[]>(this.loadFromStorage());
readonly items = this._items.asReadonly();
readonly itemCount = computed(() =>
this._items().reduce((sum, item) => sum + item.quantity, 0)
);
readonly total = computed(() =>
this._items().reduce((sum, item) => sum + item.price * item.quantity, 0)
);
addItem(product: { id: number; name: string; price: number }) {
this._items.update(items => {
const existing = items.find(i => i.productId === product.id);
if (existing) {
return items.map(i =>
i.productId === product.id
? { ...i, quantity: i.quantity + 1 }
: i
);
}
return [...items, {
productId: product.id,
name: product.name,
price: product.price,
quantity: 1
}];
});
this.saveToStorage();
}
removeItem(productId: number) {
this._items.update(items =>
items.filter(i => i.productId !== productId)
);
this.saveToStorage();
}
updateQuantity(productId: number, quantity: number) {
if (quantity <= 0) {
this.removeItem(productId);
return;
}
this._items.update(items =>
items.map(i =>
i.productId === productId ? { ...i, quantity } : i
)
);
this.saveToStorage();
}
clear() {
this._items.set([]);
this.saveToStorage();
}
private loadFromStorage(): CartItem[] {
const data = localStorage.getItem('cart');
return data ? JSON.parse(data) : [];
}
private saveToStorage() {
localStorage.setItem('cart', JSON.stringify(this._items()));
}
}
Summary
1
Services
Use @Injectable to create reusable, testable services
2
inject() Function
Modern way to inject dependencies, works everywhere
3
Injector Hierarchy
Services can be scoped to root, module, or component level
4
Providers
Configure with useClass, useValue, useFactory, or useExisting
5
InjectionTokens
Use for non-class dependencies and configuration
Next Steps
Next: Angular Signals
Deep dive into Angular’s reactive primitive for modern state management