Angular Services and Dependency Injection

Module 14: JavaScript Frontend Frameworks - Vue & Angular

Introduction to Angular Services

Services in Angular are a fundamental building block that provide a way to organize and share code across your application. They're typically used for tasks that aren't directly related to the view layer, such as:

Real-world analogy: If components are like the various stations in a factory (the assembly line, quality control, etc.), then services are like the utility providers (electricity, water, maintenance) that support multiple stations. They provide essential functions that many parts of the factory need, but don't belong to any specific station.

flowchart TD A[Angular Application] --> B[Components] A --> C[Services] B --> D[Component 1] B --> E[Component 2] B --> F[Component 3] C --> G[Data Service] C --> H[Authentication Service] C --> I[Logging Service] D -.-> G D -.-> H E -.-> G F -.-> G F -.-> I style A fill:#DD0031,color:white style B fill:#C3002F,color:white style C fill:#DD0031,color:white style D fill:#C3002F,color:white style E fill:#C3002F,color:white style F fill:#C3002F,color:white style G fill:#DD0031,color:white style H fill:#DD0031,color:white style I fill:#DD0031,color:white

Creating and Using Services

Let's explore how to create and use services in Angular:

Creating a Basic Service

We can create a service using the Angular CLI:

ng generate service services/data
# or the shorthand
ng g s services/data

This creates a service with the injectable decorator:

// data.service.ts
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class DataService {
  constructor() { }
  
  getData() {
    return [
      { id: 1, name: 'Item 1' },
      { id: 2, name: 'Item 2' },
      { id: 3, name: 'Item 3' }
    ];
  }
}

Using a Service in a Component

We can use this service in a component by injecting it:

// item-list.component.ts
import { Component, OnInit } from '@angular/core';
import { DataService } from '../services/data.service';

@Component({
  selector: 'app-item-list',
  template: `
    <h2>Items</h2>
    <ul>
      <li *ngFor="let item of items">
        {{ item.name }}
      </li>
    </ul>
  `
})
export class ItemListComponent implements OnInit {
  items: any[] = [];
  
  constructor(private dataService: DataService) { }
  
  ngOnInit(): void {
    this.items = this.dataService.getData();
  }
}

Key benefits of this approach:

Understanding Dependency Injection

Dependency Injection (DI) is a design pattern where a class receives its dependencies from external sources rather than creating them itself.

Real-world analogy: Think of DI like a professional kitchen. When a chef (component) needs a knife (service), they don't make the knife themselves or go to the store to buy one. Instead, the kitchen manager (Angular's DI system) provides the appropriate knife from the kitchen's inventory. This allows chefs to focus on cooking (component logic) rather than tool creation or acquisition.

How Angular's DI Works

flowchart TD A[Component requests
service in constructor] --> B[Angular's DI system
checks injector hierarchy] B --> C{Service
exists?} C -->|Yes| D[Return existing
service instance] C -->|No| E[Create new
service instance] E --> F[Register instance
in injector] F --> D D --> G[Service injected
into component] style A fill:#C3002F,color:white style B fill:#DD0031,color:white style C fill:#DD0031,color:white style D fill:#C3002F,color:white style E fill:#C3002F,color:white style F fill:#DD0031,color:white style G fill:#C3002F,color:white

The Injectable Decorator

The @Injectable() decorator marks a class as available to the DI system:

@Injectable({
  providedIn: 'root'
})
export class LoggingService {
  log(message: string) {
    console.log(`LOG: ${message}`);
  }
}

The providedIn: 'root' option makes the service available throughout the application as a singleton.

Injection Hierarchy

Angular's DI system uses a hierarchical injector system that follows the component tree:

flowchart TD A[Root Injector
providedIn: 'root'] --> B[ModuleInjector
NgModule providers] B --> C[ElementInjector
Component providers] C --> D[Child Component
ElementInjector] style A fill:#DD0031,color:white style B fill:#C3002F,color:white style C fill:#C3002F,color:white style D fill:#C3002F,color:white

When a component requests a service:

  1. Angular first checks the injector of that component
  2. If not found, it checks the parent component's injector
  3. It continues up the ancestry chain
  4. If still not found, it checks module injectors
  5. Finally, it checks the root injector

Service Registration: Provider Options

There are several ways to register services with Angular's DI system:

1. Root-level Providers (Application-wide Singleton)

@Injectable({
  providedIn: 'root'
})
export class DataService { }

// This is equivalent to:
@NgModule({
  providers: [DataService]
})
export class AppModule { }

2. Module-level Providers (Module-wide Singleton)

@Injectable({
  providedIn: SomeModule
})
export class DataService { }

// Or in the module declaration:
@NgModule({
  providers: [DataService]
})
export class SomeModule { }

3. Component-level Providers (Component-specific Instance)

@Component({
  selector: 'app-some-component',
  templateUrl: './some.component.html',
  providers: [DataService]
})
export class SomeComponent { }

Different Provider Syntaxes

// Shorthand (class is both the token and the implementation)
providers: [DataService]

// Expanded (useClass)
providers: [
  { provide: DataService, useClass: DataService }
]

// Using a different implementation
providers: [
  { provide: DataService, useClass: MockDataService }
]

// Using a factory
providers: [
  { 
    provide: DataService, 
    useFactory: () => {
      return environment.production
        ? new ProductionDataService()
        : new MockDataService();
    }
  }
]

// Using a value
providers: [
  { 
    provide: 'API_URL', 
    useValue: 'https://api.example.com' 
  }
]

// Using an existing service
providers: [
  { 
    provide: LogService, 
    useExisting: ConsoleLogService 
  }
]

Service Lifecycles Based on Provider Level

Root-level Providers

  • Created when the application starts
  • Destroyed when the application shuts down
  • Same instance shared across all components
  • Ideal for: app-wide services like authentication, logging

Module-level Providers

  • Created when the module is loaded
  • Shared among components within the module
  • Destroyed when the module is unloaded (lazy-loaded modules)
  • Ideal for: feature-specific services

Component-level Providers

  • Created when the component is created
  • Destroyed when the component is destroyed
  • New instance for each component instance
  • Ideal for: component-specific state or behavior

Injection Tokens

Injection tokens provide a way to identify dependencies beyond just class types:

// Defining a string token
import { InjectionToken } from '@angular/core';

export const API_URL = new InjectionToken<string>('api.url');

// Providing a value with the token
@NgModule({
  providers: [
    { provide: API_URL, useValue: 'https://api.example.com' }
  ]
})
export class AppModule { }

// Injecting the value
import { Inject } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class DataService {
  constructor(@Inject(API_URL) private apiUrl: string) {
    console.log(`Using API URL: ${apiUrl}`);
  }
}

When to use Injection Tokens:

Service Design Patterns

Let's explore some common patterns for creating and using services in Angular applications:

Data Service Pattern

Data services encapsulate data access logic, often communicating with APIs:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { User } from '../models/user.model';

@Injectable({
  providedIn: 'root'
})
export class UserService {
  private apiUrl = 'https://api.example.com/users';
  
  constructor(private http: HttpClient) { }
  
  getUsers(): Observable<User[]> {
    return this.http.get<User[]>(this.apiUrl)
      .pipe(
        catchError(this.handleError)
      );
  }
  
  getUser(id: number): Observable<User> {
    return this.http.get<User>(`${this.apiUrl}/${id}`)
      .pipe(
        catchError(this.handleError)
      );
  }
  
  createUser(user: User): Observable<User> {
    return this.http.post<User>(this.apiUrl, user)
      .pipe(
        catchError(this.handleError)
      );
  }
  
  updateUser(user: User): Observable<User> {
    return this.http.put<User>(`${this.apiUrl}/${user.id}`, user)
      .pipe(
        catchError(this.handleError)
      );
  }
  
  deleteUser(id: number): Observable<any> {
    return this.http.delete(`${this.apiUrl}/${id}`)
      .pipe(
        catchError(this.handleError)
      );
  }
  
  private handleError(error: any): Observable<never> {
    console.error('An error occurred:', error);
    return throwError(() => error);
  }
}

Singleton State Service Pattern

State services maintain shared state across components:

import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { User } from '../models/user.model';

@Injectable({
  providedIn: 'root'
})
export class UserStateService {
  private currentUserSubject = new BehaviorSubject<User | null>(null);
  currentUser$ = this.currentUserSubject.asObservable();
  
  private isLoadingSubject = new BehaviorSubject<boolean>(false);
  isLoading$ = this.isLoadingSubject.asObservable();
  
  constructor() { }
  
  setCurrentUser(user: User): void {
    this.currentUserSubject.next(user);
  }
  
  clearCurrentUser(): void {
    this.currentUserSubject.next(null);
  }
  
  setLoading(isLoading: boolean): void {
    this.isLoadingSubject.next(isLoading);
  }
  
  // If we need the current value synchronously
  get currentUser(): User | null {
    return this.currentUserSubject.value;
  }
  
  get isLoading(): boolean {
    return this.isLoadingSubject.value;
  }
}

Utility/Helper Service Pattern

Utility services provide reusable functionality:

import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class UtilityService {
  constructor() { }
  
  formatDate(date: Date): string {
    // Custom date formatting logic
    return date.toLocaleDateString();
  }
  
  formatCurrency(amount: number, currency = 'USD'): string {
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency
    }).format(amount);
  }
  
  debounce(func: Function, wait: number): Function {
    let timeout: any;
    return function(...args: any[]) {
      clearTimeout(timeout);
      timeout = setTimeout(() => func.apply(this, args), wait);
    };
  }
  
  generateUUID(): string {
    // UUID generation logic
    return 'xxxxx-xxxxx-xxxxx-xxxxx'.replace(/x/g, () => {
      return Math.floor(Math.random() * 16).toString(16);
    });
  }
}

Facade Service Pattern

Facade services simplify complex interactions by providing a unified API:

import { Injectable } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { catchError, map, switchMap, tap } from 'rxjs/operators';
import { UserService } from './user.service';
import { AuthService } from './auth.service';
import { UserStateService } from './user-state.service';
import { User } from '../models/user.model';

@Injectable({
  providedIn: 'root'
})
export class UserFacadeService {
  // Expose state as Observables
  currentUser$ = this.userStateService.currentUser$;
  isLoading$ = this.userStateService.isLoading$;
  
  constructor(
    private userService: UserService,
    private authService: AuthService,
    private userStateService: UserStateService
  ) { }
  
  loadUser(id: number): Observable<User> {
    this.userStateService.setLoading(true);
    
    return this.userService.getUser(id).pipe(
      tap(user => {
        this.userStateService.setCurrentUser(user);
        this.userStateService.setLoading(false);
      }),
      catchError(error => {
        this.userStateService.setLoading(false);
        return throwError(() => error);
      })
    );
  }
  
  updateUserProfile(userChanges: Partial<User>): Observable<User> {
    if (!this.userStateService.currentUser) {
      return throwError(() => new Error('No user loaded'));
    }
    
    const updatedUser = { 
      ...this.userStateService.currentUser, 
      ...userChanges 
    };
    
    this.userStateService.setLoading(true);
    
    return this.userService.updateUser(updatedUser).pipe(
      tap(user => {
        this.userStateService.setCurrentUser(user);
        this.userStateService.setLoading(false);
      }),
      catchError(error => {
        this.userStateService.setLoading(false);
        return throwError(() => error);
      })
    );
  }
  
  // Complex operation involving multiple services
  registerAndLoadUser(newUser: User): Observable<User> {
    this.userStateService.setLoading(true);
    
    return this.authService.register(newUser).pipe(
      switchMap(response => this.authService.login(newUser.email, newUser.password)),
      switchMap(authResponse => this.userService.getUser(authResponse.userId)),
      tap(user => {
        this.userStateService.setCurrentUser(user);
        this.userStateService.setLoading(false);
      }),
      catchError(error => {
        this.userStateService.setLoading(false);
        return throwError(() => error);
      })
    );
  }
}

Services vs Components: Separation of Concerns

Understanding when to put logic in services versus components is key to a well-structured Angular application:

Component Responsibilities

  • UI presentation logic
  • Handling user interactions
  • Managing component-specific state
  • Coordinating between child components
  • Delegating data operations to services

Service Responsibilities

  • Data fetching and persistence
  • Business logic
  • Shared functionality
  • Application-wide or feature-wide state
  • Communication with external systems

Rule of thumb: If the logic is related to how something looks or handles direct user interaction, it belongs in a component. If it's about what the application does or how it works, it belongs in a service.

Benefits of Proper Separation

Service Communication with Observables

Angular services often use RxJS Observables for asynchronous operations and communication:

HTTP Requests

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Product } from '../models/product.model';

@Injectable({
  providedIn: 'root'
})
export class ProductService {
  private apiUrl = 'https://api.example.com/products';
  
  constructor(private http: HttpClient) { }
  
  getProducts(): Observable<Product[]> {
    return this.http.get<Product[]>(this.apiUrl);
  }
}

State Management with BehaviorSubject

import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { CartItem } from '../models/cart-item.model';

@Injectable({
  providedIn: 'root'
})
export class CartService {
  private cartItemsSubject = new BehaviorSubject<CartItem[]>([]);
  cartItems$: Observable<CartItem[]> = this.cartItemsSubject.asObservable();
  
  constructor() { }
  
  addToCart(item: CartItem): void {
    const currentItems = this.cartItemsSubject.value;
    const existingItemIndex = currentItems.findIndex(i => i.id === item.id);
    
    if (existingItemIndex !== -1) {
      // Update quantity if item exists
      const updatedItems = [...currentItems];
      updatedItems[existingItemIndex] = {
        ...currentItems[existingItemIndex],
        quantity: currentItems[existingItemIndex].quantity + item.quantity
      };
      this.cartItemsSubject.next(updatedItems);
    } else {
      // Add new item
      this.cartItemsSubject.next([...currentItems, item]);
    }
  }
  
  removeFromCart(id: number): void {
    const currentItems = this.cartItemsSubject.value;
    this.cartItemsSubject.next(currentItems.filter(item => item.id !== id));
  }
  
  clearCart(): void {
    this.cartItemsSubject.next([]);
  }
  
  get totalItems(): number {
    return this.cartItemsSubject.value.reduce(
      (total, item) => total + item.quantity, 0
    );
  }
  
  get totalPrice(): number {
    return this.cartItemsSubject.value.reduce(
      (total, item) => total + (item.price * item.quantity), 0
    );
  }
}

Using Services with Observables in Components

import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import { CartService } from '../../services/cart.service';
import { CartItem } from '../../models/cart-item.model';

@Component({
  selector: 'app-cart',
  template: `
    <div class="cart">
      <h2>Your Cart</h2>
      
      <div *ngIf="cartItems.length === 0" class="empty-cart">
        Your cart is empty
      </div>
      
      <div *ngIf="cartItems.length > 0">
        <div *ngFor="let item of cartItems" class="cart-item">
          <span>{{ item.name }}</span>
          <span>{{ item.quantity }} × {{ item.price | currency }}</span>
          <button (click)="removeItem(item.id)">Remove</button>
        </div>
        
        <div class="cart-total">
          <strong>Total: {{ totalPrice | currency }}</strong>
        </div>
        
        <button (click)="clearCart()">Clear Cart</button>
        <button>Checkout</button>
      </div>
    </div>
  `
})
export class CartComponent implements OnInit, OnDestroy {
  cartItems: CartItem[] = [];
  totalPrice = 0;
  private cartSubscription: Subscription;
  
  constructor(private cartService: CartService) { }
  
  ngOnInit(): void {
    this.cartSubscription = this.cartService.cartItems$.subscribe(items => {
      this.cartItems = items;
      this.totalPrice = this.cartService.totalPrice;
    });
  }
  
  removeItem(id: number): void {
    this.cartService.removeFromCart(id);
  }
  
  clearCart(): void {
    this.cartService.clearCart();
  }
  
  ngOnDestroy(): void {
    // Always unsubscribe to prevent memory leaks
    if (this.cartSubscription) {
      this.cartSubscription.unsubscribe();
    }
  }
}

Async Pipe Alternative

The async pipe automatically subscribes to and unsubscribes from Observables:

import { Component } from '@angular/core';
import { Observable } from 'rxjs';
import { CartService } from '../../services/cart.service';
import { CartItem } from '../../models/cart-item.model';

@Component({
  selector: 'app-cart',
  template: `
    <div class="cart">
      <h2>Your Cart</h2>
      
      <ng-container *ngIf="cartItems$ | async as cartItems">
        <div *ngIf="cartItems.length === 0" class="empty-cart">
          Your cart is empty
        </div>
        
        <div *ngIf="cartItems.length > 0">
          <div *ngFor="let item of cartItems" class="cart-item">
            <span>{{ item.name }}</span>
            <span>{{ item.quantity }} × {{ item.price | currency }}</span>
            <button (click)="removeItem(item.id)">Remove</button>
          </div>
          
          <div class="cart-total">
            <strong>Total: {{ totalPrice$ | async | currency }}</strong>
          </div>
          
          <button (click)="clearCart()">Clear Cart</button>
          <button>Checkout</button>
        </div>
      </ng-container>
    </div>
  `
})
export class CartComponent {
  cartItems$: Observable<CartItem[]>;
  totalPrice$: Observable<number>;
  
  constructor(private cartService: CartService) {
    this.cartItems$ = this.cartService.cartItems$;
    this.totalPrice$ = this.cartService.totalPrice$;
  }
  
  removeItem(id: number): void {
    this.cartService.removeFromCart(id);
  }
  
  clearCart(): void {
    this.cartService.clearCart();
  }
}

Benefits of the async pipe approach:

Advanced Dependency Injection Techniques

Let's explore some advanced DI techniques that can be useful in larger applications:

Multi Providers

Multi providers allow multiple services to be injected for a single token:

// Define the token and interface
import { InjectionToken } from '@angular/core';

export interface Logger {
  log(message: string): void;
}

export const LOGGERS = new InjectionToken<Logger[]>('Loggers');

// Create implementations
@Injectable()
export class ConsoleLogger implements Logger {
  log(message: string): void {
    console.log(`[Console]: ${message}`);
  }
}

@Injectable()
export class DatabaseLogger implements Logger {
  constructor(private dbService: DbService) {}
  
  log(message: string): void {
    this.dbService.saveLog(message);
  }
}

@Injectable()
export class ApiLogger implements Logger {
  constructor(private http: HttpClient) {}
  
  log(message: string): void {
    this.http.post('api/logs', { message }).subscribe();
  }
}

// Register with multi: true
@NgModule({
  providers: [
    { provide: LOGGERS, useClass: ConsoleLogger, multi: true },
    { provide: LOGGERS, useClass: DatabaseLogger, multi: true },
    { provide: LOGGERS, useClass: ApiLogger, multi: true }
  ]
})
export class AppModule { }

// Use in a service
@Injectable({ providedIn: 'root' })
export class LoggingService {
  constructor(@Inject(LOGGERS) private loggers: Logger[]) {}
  
  log(message: string): void {
    // Log to all registered loggers
    this.loggers.forEach(logger => logger.log(message));
  }
}

Factory Providers with Dependencies

Factory functions can access other injected services:

@NgModule({
  providers: [
    {
      provide: 'ConfiguredApiService',
      useFactory: (configService: ConfigService, http: HttpClient) => {
        const apiUrl = configService.getApiUrl();
        return new ApiService(apiUrl, http);
      },
      deps: [ConfigService, HttpClient]
    }
  ]
})
export class AppModule { }

Optional Dependencies

You can mark dependencies as optional to prevent errors if they're not available:

import { Injectable, Optional } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class AnalyticsService {
  constructor(
    @Optional() private logger: LoggingService
  ) {
    if (this.logger) {
      this.logger.log('AnalyticsService initialized');
    }
  }
  
  trackEvent(event: string): void {
    // Track the event
    
    if (this.logger) {
      this.logger.log(`Event tracked: ${event}`);
    }
  }
}

Self, SkipSelf, and Host

These modifiers control where Angular looks for dependencies:

import { Injectable, Self, SkipSelf, Host, Optional } from '@angular/core';

@Component({
  selector: 'app-child',
  templateUrl: './child.component.html',
  providers: [
    { provide: LoggingService, useClass: ChildLoggingService }
  ]
})
export class ChildComponent {
  constructor(
    // Only look in this component's injector
    @Self() private selfLogger: LoggingService,
    
    // Skip this component's injector, look in parent
    @SkipSelf() private parentLogger: LoggingService,
    
    // Only look in this component's host component
    @Host() @Optional() private hostLogger: LoggingService
  ) {
    this.selfLogger.log('Using child component logger');
    this.parentLogger.log('Using parent logger');
    
    if (this.hostLogger) {
      this.hostLogger.log('Using host logger');
    }
  }
}

Testing Services

Services are typically easier to test than components because they don't involve the DOM. Here are some testing strategies:

Basic Service Testing

// calculator.service.ts
import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class CalculatorService {
  add(a: number, b: number): number {
    return a + b;
  }
  
  subtract(a: number, b: number): number {
    return a - b;
  }
}

// calculator.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { CalculatorService } from './calculator.service';

describe('CalculatorService', () => {
  let service: CalculatorService;
  
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [CalculatorService]
    });
    service = TestBed.inject(CalculatorService);
  });
  
  it('should be created', () => {
    expect(service).toBeTruthy();
  });
  
  it('should add two numbers', () => {
    expect(service.add(2, 3)).toBe(5);
    expect(service.add(-1, 1)).toBe(0);
  });
  
  it('should subtract two numbers', () => {
    expect(service.subtract(5, 2)).toBe(3);
    expect(service.subtract(1, 2)).toBe(-1);
  });
});

Testing Services with Dependencies

// data.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Product } from '../models/product.model';

@Injectable({ providedIn: 'root' })
export class ProductService {
  private apiUrl = 'https://api.example.com/products';
  
  constructor(private http: HttpClient) { }
  
  getProducts(): Observable<Product[]> {
    return this.http.get<Product[]>(this.apiUrl);
  }
}

// data.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { ProductService } from './product.service';
import { Product } from '../models/product.model';

describe('ProductService', () => {
  let service: ProductService;
  let httpMock: HttpTestingController;
  
  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [ProductService]
    });
    
    service = TestBed.inject(ProductService);
    httpMock = TestBed.inject(HttpTestingController);
  });
  
  afterEach(() => {
    // Verify no outstanding requests
    httpMock.verify();
  });
  
  it('should retrieve products', () => {
    const mockProducts: Product[] = [
      { id: 1, name: 'Product 1', price: 10 },
      { id: 2, name: 'Product 2', price: 20 }
    ];
    
    service.getProducts().subscribe(products => {
      expect(products.length).toBe(2);
      expect(products).toEqual(mockProducts);
    });
    
    // Expect a request to the specified URL
    const req = httpMock.expectOne('https://api.example.com/products');
    expect(req.request.method).toBe('GET');
    
    // Respond with mock data
    req.flush(mockProducts);
  });
});

Testing with Mock Services

// component using a service
@Component({
  selector: 'app-product-list',
  template: `
    <div *ngIf="products.length">
      <div *ngFor="let product of products">{{ product.name }}</div>
    </div>
  `
})
export class ProductListComponent implements OnInit {
  products: Product[] = [];
  
  constructor(private productService: ProductService) { }
  
  ngOnInit(): void {
    this.productService.getProducts()
      .subscribe(products => this.products = products);
  }
}

// component test with mock service
describe('ProductListComponent', () => {
  let component: ProductListComponent;
  let fixture: ComponentFixture<ProductListComponent>;
  let mockProductService: jasmine.SpyObj<ProductService>;
  
  beforeEach(async () => {
    // Create a mock service
    mockProductService = jasmine.createSpyObj('ProductService', ['getProducts']);
    
    await TestBed.configureTestingModule({
      declarations: [ProductListComponent],
      providers: [
        { provide: ProductService, useValue: mockProductService }
      ]
    }).compileComponents();
    
    // Set up the mock to return test data
    mockProductService.getProducts.and.returnValue(of([
      { id: 1, name: 'Test Product 1', price: 10 },
      { id: 2, name: 'Test Product 2', price: 20 }
    ]));
    
    fixture = TestBed.createComponent(ProductListComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });
  
  it('should create', () => {
    expect(component).toBeTruthy();
  });
  
  it('should load products on init', () => {
    expect(mockProductService.getProducts).toHaveBeenCalled();
    expect(component.products.length).toBe(2);
    expect(component.products[0].name).toBe('Test Product 1');
  });
});

Practical Example: Building a Complete Service Architecture

Let's bring everything together with a practical example of a service architecture for a product management system:

flowchart TD A[Component Layer] --> B[Facade Services] B --> C[Data Services] B --> D[State Services] B --> E[Utility Services] C --> F[HTTP Client] style A fill:#C3002F,color:white style B fill:#DD0031,color:white style C fill:#DD0031,color:white style D fill:#DD0031,color:white style E fill:#DD0031,color:white style F fill:#C3002F,color:white

1. API Configuration Service

// config.service.ts
import { Injectable } from '@angular/core';
import { environment } from '../../environments/environment';

@Injectable({
  providedIn: 'root'
})
export class ConfigService {
  private apiUrl = environment.apiUrl;
  
  getApiUrl(): string {
    return this.apiUrl;
  }
  
  getProductsEndpoint(): string {
    return `${this.apiUrl}/products`;
  }
  
  getProductEndpoint(id: number): string {
    return `${this.apiUrl}/products/${id}`;
  }
}

2. HTTP Error Handling Service

// error-handler.service.ts
import { Injectable } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class ErrorHandlerService {
  handleError(error: HttpErrorResponse): Observable<never> {
    let errorMessage = 'An unknown error occurred!';
    
    if (error.error instanceof ErrorEvent) {
      // Client-side error
      errorMessage = `Error: ${error.error.message}`;
    } else {
      // Server-side error
      errorMessage = `Error Code: ${error.status}\nMessage: ${error.message}`;
    }
    
    console.error(errorMessage);
    return throwError(() => new Error(errorMessage));
  }
}

3. Product Data Service

// product-data.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { ConfigService } from './config.service';
import { ErrorHandlerService } from './error-handler.service';
import { Product } from '../models/product.model';

@Injectable({
  providedIn: 'root'
})
export class ProductDataService {
  constructor(
    private http: HttpClient,
    private configService: ConfigService,
    private errorHandler: ErrorHandlerService
  ) { }
  
  getProducts(category?: string): Observable<Product[]> {
    let params = new HttpParams();
    if (category) {
      params = params.set('category', category);
    }
    
    return this.http.get<Product[]>(
      this.configService.getProductsEndpoint(),
      { params }
    ).pipe(
      catchError(error => this.errorHandler.handleError(error))
    );
  }
  
  getProduct(id: number): Observable<Product> {
    return this.http.get<Product>(
      this.configService.getProductEndpoint(id)
    ).pipe(
      catchError(error => this.errorHandler.handleError(error))
    );
  }
  
  createProduct(product: Product): Observable<Product> {
    return this.http.post<Product>(
      this.configService.getProductsEndpoint(),
      product
    ).pipe(
      catchError(error => this.errorHandler.handleError(error))
    );
  }
  
  updateProduct(product: Product): Observable<Product> {
    return this.http.put<Product>(
      this.configService.getProductEndpoint(product.id),
      product
    ).pipe(
      catchError(error => this.errorHandler.handleError(error))
    );
  }
  
  deleteProduct(id: number): Observable<any> {
    return this.http.delete(
      this.configService.getProductEndpoint(id)
    ).pipe(
      catchError(error => this.errorHandler.handleError(error))
    );
  }
}

4. Product State Service

// product-state.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { Product } from '../models/product.model';

@Injectable({
  providedIn: 'root'
})
export class ProductStateService {
  private productsSubject = new BehaviorSubject<Product[]>([]);
  products$ = this.productsSubject.asObservable();
  
  private selectedProductSubject = new BehaviorSubject<Product | null>(null);
  selectedProduct$ = this.selectedProductSubject.asObservable();
  
  private loadingSubject = new BehaviorSubject<boolean>(false);
  loading$ = this.loadingSubject.asObservable();
  
  private errorSubject = new BehaviorSubject<string | null>(null);
  error$ = this.errorSubject.asObservable();
  
  setProducts(products: Product[]): void {
    this.productsSubject.next(products);
  }
  
  setSelectedProduct(product: Product | null): void {
    this.selectedProductSubject.next(product);
  }
  
  setLoading(loading: boolean): void {
    this.loadingSubject.next(loading);
  }
  
  setError(error: string | null): void {
    this.errorSubject.next(error);
  }
  
  addProduct(product: Product): void {
    const currentProducts = this.productsSubject.value;
    this.productsSubject.next([...currentProducts, product]);
  }
  
  updateProduct(updatedProduct: Product): void {
    const currentProducts = this.productsSubject.value;
    this.productsSubject.next(
      currentProducts.map(p => 
        p.id === updatedProduct.id ? updatedProduct : p
      )
    );
    
    // Update selected product if it's the same one
    if (this.selectedProductSubject.value?.id === updatedProduct.id) {
      this.selectedProductSubject.next(updatedProduct);
    }
  }
  
  removeProduct(id: number): void {
    const currentProducts = this.productsSubject.value;
    this.productsSubject.next(
      currentProducts.filter(p => p.id !== id)
    );
    
    // Clear selected product if it's the same one
    if (this.selectedProductSubject.value?.id === id) {
      this.selectedProductSubject.next(null);
    }
  }
  
  // Helper methods to get current values synchronously
  get products(): Product[] {
    return this.productsSubject.value;
  }
  
  get selectedProduct(): Product | null {
    return this.selectedProductSubject.value;
  }
}

5. Product Facade Service

// product-facade.service.ts
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { tap, finalize, catchError, map } from 'rxjs/operators';
import { ProductDataService } from './product-data.service';
import { ProductStateService } from './product-state.service';
import { Product } from '../models/product.model';

@Injectable({
  providedIn: 'root'
})
export class ProductFacadeService {
  // Expose the state as observables for components
  products$ = this.productState.products$;
  selectedProduct$ = this.productState.selectedProduct$;
  loading$ = this.productState.loading$;
  error$ = this.productState.error$;
  
  constructor(
    private productData: ProductDataService,
    private productState: ProductStateService
  ) { }
  
  loadProducts(category?: string): void {
    this.productState.setLoading(true);
    this.productState.setError(null);
    
    this.productData.getProducts(category).pipe(
      tap(products => {
        this.productState.setProducts(products);
      }),
      catchError(error => {
        this.productState.setError('Failed to load products.');
        throw error;
      }),
      finalize(() => {
        this.productState.setLoading(false);
      })
    ).subscribe();
  }
  
  loadProduct(id: number): void {
    this.productState.setLoading(true);
    this.productState.setError(null);
    
    this.productData.getProduct(id).pipe(
      tap(product => {
        this.productState.setSelectedProduct(product);
      }),
      catchError(error => {
        this.productState.setError(`Failed to load product #${id}.`);
        throw error;
      }),
      finalize(() => {
        this.productState.setLoading(false);
      })
    ).subscribe();
  }
  
  createProduct(product: Product): Observable<Product> {
    this.productState.setLoading(true);
    this.productState.setError(null);
    
    return this.productData.createProduct(product).pipe(
      tap(newProduct => {
        this.productState.addProduct(newProduct);
      }),
      catchError(error => {
        this.productState.setError('Failed to create product.');
        throw error;
      }),
      finalize(() => {
        this.productState.setLoading(false);
      })
    );
  }
  
  updateProduct(product: Product): Observable<Product> {
    this.productState.setLoading(true);
    this.productState.setError(null);
    
    return this.productData.updateProduct(product).pipe(
      tap(updatedProduct => {
        this.productState.updateProduct(updatedProduct);
      }),
      catchError(error => {
        this.productState.setError(`Failed to update product #${product.id}.`);
        throw error;
      }),
      finalize(() => {
        this.productState.setLoading(false);
      })
    );
  }
  
  deleteProduct(id: number): Observable<boolean> {
    this.productState.setLoading(true);
    this.productState.setError(null);
    
    return this.productData.deleteProduct(id).pipe(
      map(() => true),
      tap(() => {
        this.productState.removeProduct(id);
      }),
      catchError(error => {
        this.productState.setError(`Failed to delete product #${id}.`);
        throw error;
      }),
      finalize(() => {
        this.productState.setLoading(false);
      })
    );
  }
  
  selectProduct(product: Product | null): void {
    this.productState.setSelectedProduct(product);
  }
  
  clearSelectedProduct(): void {
    this.productState.setSelectedProduct(null);
  }
}

6. Using the Services in Components

// product-list.component.ts
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { ProductFacadeService } from '../../services/product-facade.service';
import { Product } from '../../models/product.model';

@Component({
  selector: 'app-product-list',
  template: `
    <div class="product-list-container">
      <h2>Products</h2>
      
      <div class="filters">
        <select (change)="filterByCategory($event)">
          <option value="">All Categories</option>
          <option value="electronics">Electronics</option>
          <option value="clothing">Clothing</option>
          <option value="books">Books</option>
        </select>
        
        <button (click)="refresh()">Refresh</button>
        <button routerLink="/products/new">Add New</button>
      </div>
      
      <div *ngIf="loading$ | async" class="loading">
        Loading products...
      </div>
      
      <div *ngIf="error$ | async as error" class="error">
        {{ error }}
      </div>
      
      <div *ngIf="(products$ | async)?.length === 0 && !(loading$ | async)" class="empty">
        No products found.
      </div>
      
      <div class="product-grid">
        <div *ngFor="let product of products$ | async" class="product-card">
          <h3>{{ product.name }}</h3>
          <p class="price">{{ product.price | currency }}</p>
          
          <div class="actions">
            <button (click)="viewProduct(product)">View</button>
            <button (click)="editProduct(product)">Edit</button>
            <button (click)="deleteProduct(product.id)">Delete</button>
          </div>
        </div>
      </div>
    </div>
  `
})
export class ProductListComponent implements OnInit {
  products$: Observable<Product[]>;
  loading$: Observable<boolean>;
  error$: Observable<string | null>;
  
  private currentCategory = '';
  
  constructor(private productFacade: ProductFacadeService) {
    this.products$ = this.productFacade.products$;
    this.loading$ = this.productFacade.loading$;
    this.error$ = this.productFacade.error$;
  }
  
  ngOnInit(): void {
    this.loadProducts();
  }
  
  loadProducts(): void {
    this.productFacade.loadProducts(this.currentCategory || undefined);
  }
  
  filterByCategory(event: Event): void {
    this.currentCategory = (event.target as HTMLSelectElement).value;
    this.loadProducts();
  }
  
  refresh(): void {
    this.loadProducts();
  }
  
  viewProduct(product: Product): void {
    this.productFacade.selectProduct(product);
    // Navigate to product detail page
  }
  
  editProduct(product: Product): void {
    this.productFacade.selectProduct(product);
    // Navigate to product edit page
  }
  
  deleteProduct(id: number): void {
    if (confirm('Are you sure you want to delete this product?')) {
      this.productFacade.deleteProduct(id).subscribe({
        next: () => {
          // Show success message
        },
        error: (err) => {
          // Error is already handled by the facade
          console.error('Delete operation failed', err);
        }
      });
    }
  }
}

This architecture demonstrates:

Activities for Practice

Exercise 1: Create a Complete Service Layer

Create a set of services for a task management application:

  1. Create a TaskDataService for API communication
  2. Create a TaskStateService for storing application state
  3. Create a TaskFacadeService to coordinate between the two
  4. Implement CRUD operations for tasks
  5. Add loading and error handling
  6. Create a simple component that uses these services

Exercise 2: Implement Advanced Dependency Injection

Create a logging system using advanced DI techniques:

  1. Create a Logger interface
  2. Create multiple implementations (console logger, storage logger, etc.)
  3. Use multi providers to register all loggers
  4. Create a LoggingService that uses all registered loggers
  5. Add a factory provider for a configurable logger
  6. Use the logging system in a component

Exercise 3: Build a Service with Reactive State

Create a shopping cart service with reactive state management:

  1. Define cart item models
  2. Create a CartService with methods to add, remove, and update items
  3. Use BehaviorSubject to track cart state
  4. Add computed values like total price and item count
  5. Implement persistence using localStorage
  6. Create a simple cart component that uses the async pipe to display the cart

Additional Resources