Lesson 03-Angular Services and Dependency Injection

Dependency Injection

Dependency Injection (DI) is a core feature in Angular that enables dynamic resolution of dependencies between components and services at runtime, rather than hardcoding them at compile time. DI enhances code testability, maintainability, and promotes loose coupling and reusability.

Basic Concepts

  • Dependency: An object relies on another object to provide specific services or information to function correctly.
  • Injection: Dependency injection delegates the resolution of dependencies to an external container (in Angular, the framework itself) rather than the object itself.
  • Service: In Angular, services are classes that provide specific functionality, such as data access, logging, or configuration management, and are used via dependency injection.

How Angular DI Works

Angular’s DI system revolves around the concept of a “Provider,” an object that instructs Angular on how to create and provide dependencies. Every Angular application has a root injector created at startup, with all other injectors being its descendants.

Provider Types:

  • Class Provider: Specifies a class as the dependency implementation.
  • Value Provider: Provides a specific value for the dependency.
  • Factory Provider: Uses a factory function to create the dependency.
  • Injection Tokens: Identifies dependencies using a class, string, or symbol.

Configuring DI

Register a service in AppModule:

import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { MyService } from './my.service';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    // Other modules
  ],
  providers: [
    MyService
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

Injecting a Service in a Component:

import { Component } from '@angular/core';
import { MyService } from './my.service';

@Component({
  selector: 'app-root',
  template: `
    <h1>Hello, {{ message }}</h1>
  `,
})
export class AppComponent {
  message: string;

  constructor(private myService: MyService) {
    this.message = this.myService.getMessage();
  }
}

Module-Level DI

Services can be registered in any module to organize and reuse code effectively.

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MyService } from './my.service';

@NgModule({
  imports: [
    CommonModule
  ],
  providers: [
    MyService
  ]
})
export class SharedModule { }

Hierarchical Injectors

Angular supports hierarchical injectors, allowing dependency registration at different levels (root module, submodules, components), with each level potentially overriding previous configurations.

Scopes

Service instances can have different lifecycles based on their scope:

  • Singleton: A single instance exists for the entire application.
  • Prototype: A new instance is created for each request.

By default, Angular services are singletons.

Best Practices for Dependency Injection

  • Use Services: Encapsulate business logic in services, not components.
  • Avoid Circular Dependencies: Ensure dependencies do not form a loop.
  • Use Factory Functions: Employ factory functions for complex dependency creation.
  • Avoid Over-Injection: Inject only the services a component needs.

Testing with DI

DI simplifies unit testing by allowing easy replacement or mocking of dependencies.

Using TestBed:

import { TestBed } from '@angular/core/testing';
import { MyService } from './my.service';

describe('MyService', () => {
  let service: MyService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [MyService]
    });
    service = TestBed.inject(MyService);
  });

  it('should return correct message', () => {
    expect(service.getMessage()).toBe('Hello, world!');
  });
});

Custom Providers

Custom providers allow control over service instance creation, useful for services requiring special initialization.

Factory Provider Example:

providers: [
  {
    provide: MyService,
    useFactory: (dependencyA: DependencyA) => {
      return new MyService(dependencyA);
    },
    deps: [DependencyA]
  }
]

The factory function creates a MyService instance, receiving DependencyA as a parameter.

Injection Tokens

Injection tokens uniquely identify dependencies, using classes, strings, or InjectionToken (replacing OpaqueToken in Angular 4+).

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

export const MY_CONFIG = new InjectionToken('MyConfig');

@NgModule({
  providers: [
    { provide: MY_CONFIG, useValue: { apiUrl: 'https://api.example.com' } }
  ]
})
export class AppModule { }

Injecting the Configuration:

constructor(@Inject(MY_CONFIG) private config: any) {
  console.log(this.config.apiUrl); // Outputs: https://api.example.com
}

Lazy-Loaded Services

In large applications, lazy-loaded services optimize performance by loading only when needed, typically within lazy-loaded modules.

const routes: Routes = [
  {
    path: 'lazy',
    loadChildren: () => import('./lazy/lazy.module').then(m => m.LazyModule)
  }
];

Services defined in lazy-loaded modules are loaded and injected only when the module is accessed.

Advanced Injection Strategies

  • Optional: Marks a dependency as optional, injecting null if unresolved.
constructor(@Optional() dependency?: SomeService) { }
  • Host: Retrieves dependencies from the host element or component, not the injector.
constructor(@Host() dependency: SomeService) { }
  • Self: Looks for dependencies only in the current component’s injector.
constructor(@Self() dependency: SomeService) { }
  • SkipSelf: Retrieves dependencies from parent injectors, skipping the current injector.
constructor(@SkipSelf() dependency: SomeService) { }

Advanced Usage: Multi Providers

Multi providers allow multiple implementations for the same token, useful for services like logging with multiple handlers.

@NgModule({
  providers: [
    { provide: LOGGER, useClass: ConsoleLogger, multi: true },
    { provide: LOGGER, useClass: FileLogger, multi: true }
  ]
})
export class AppModule { }

Consuming Multi Providers:

constructor(@Inject(LOGGER) loggers: Logger[]) {
  loggers.forEach(logger => logger.log('Logging...'));
}

Services

Angular services encapsulate business logic, data access, and state management, reusable across components via dependency injection, enhancing maintainability and testability.

Creating a Service

Services are classes with business logic, injectable into components or other services.

// services/my-service.service.ts
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root' // Or 'any' or a specific module
})
export class MyService {
  constructor() { }

  getData(): string {
    return 'Hello from MyService!';
  }
}

The @Injectable() decorator marks the service for dependency injection, with providedIn specifying its scope.

Injecting a Service

Inject a service into a component by declaring it in the constructor.

// components/app.component.ts
import { Component } from '@angular/core';
import { MyService } from './services/my-service.service';

@Component({
  selector: 'app-root',
  template: `<h1>{{ data }}</h1>`
})
export class AppComponent {
  data: string;

  constructor(private myService: MyService) {
    this.data = this.myService.getData();
  }
}

Inter-Service Communication

Services can depend on each other, communicating via dependency injection.

// services/data.service.ts
@Injectable({ providedIn: 'root' })
export class DataService {
  private data = 'Initial Data';

  setData(newData: string) {
    this.data = newData;
  }

  getData() {
    return this.data;
  }
}

// services/log.service.ts
@Injectable({ providedIn: 'root' })
export class LogService {
  constructor(private dataService: DataService) {}

  logData() {
    console.log(this.dataService.getData());
  }
}

Service Lifecycle

Angular’s dependency injection system manages service instantiation and destruction. By default, services are singletons, with one instance per application.

Testing Services

Service tests often use mocks to isolate dependencies.

// services/my-service.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { MyService } from './my-service.service';

describe('MyService', () => {
  let service: MyService;

  beforeEach(() => {
    TestBed.configureTestingModule({});
    service = TestBed.inject(MyService);
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  it('should return correct data', () => {
    expect(service.getData()).toEqual('Hello from MyService!');
  });
});

Services and State Management

For complex state management, use NgRx or RxJS’s BehaviorSubject.

// services/state.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class StateService {
  private state$ = new BehaviorSubject<{ count: number }>({ count: 0 });

  getState() {
    return this.state$.asObservable();
  }

  updateState(newCount: number) {
    this.state$.next({ count: newCount });
  }
}

Subscribing to State in a Component:

// components/app.component.ts
import { Component, OnInit } from '@angular/core';
import { StateService } from './services/state.service';

@Component({
  selector: 'app-root',
  template: `<h1>Count: {{ count }}</h1>`
})
export class AppComponent implements OnInit {
  count: number;

  constructor(private stateService: StateService) {}

  ngOnInit() {
    this.stateService.getState().subscribe(state => {
      this.count = state.count;
    });
  }
}

Services and Network Requests

Services often encapsulate HTTP requests using Angular’s HttpClient module.

// services/http.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Injectable({ providedIn: 'root' })
export class HttpService {
  constructor(private http: HttpClient) {}

  fetchData() {
    return this.http.get('https://api.example.com/data');
  }
}

Services and Error Handling

Services can centralize HTTP error handling for consistent responses.

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

@Injectable({ providedIn: 'root' })
export class ErrorHandlerService {
  handle(errorResponse: HttpErrorResponse) {
    console.error('Error:', errorResponse);
  }
}

HTTP Client and Angular HttpClient

Angular’s HTTP client API, primarily the HttpClient module, facilitates interaction with web servers. It supports HTTP methods like GET, POST, PUT, and DELETE, and integrates with RxJS for seamless asynchronous data handling.

Importing HttpClient

To use HttpClient, import HttpClientModule in the module.

// app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';

import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    HttpClientModule // Import HttpClientModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Using HttpClient

Inject HttpClient into a service to make HTTP requests.

// services/api.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Injectable({
  providedIn: 'root'
})
export class ApiService {
  constructor(private http: HttpClient) { }

  getData() {
    return this.http.get('https://api.example.com/data');
  }

  postData(data: any) {
    return this.http.post('https://api.example.com/data', data);
  }
}

Handling Responses

HttpClient methods return RxJS Observable objects, requiring subscription to access results.

// components/app.component.ts
import { Component, OnInit } from '@angular/core';
import { ApiService } from './services/api.service';

@Component({
  selector: 'app-root',
  template: `
    <h1>Data from Server:</h1>
    <pre>{{ data | json }}</pre>
  `
})
export class AppComponent implements OnInit {
  data: any;

  constructor(private apiService: ApiService) { }

  ngOnInit() {
    this.apiService.getData()
      .subscribe(response => {
        this.data = response;
      }, error => {
        console.error('Error fetching data:', error);
      });
  }
}

Error Handling

Handle HTTP errors using RxJS’s catchError operator.

import { catchError } from 'rxjs/operators';

getData() {
  return this.http.get('https://api.example.com/data')
    .pipe(
      catchError(this.handleError)
    );
}

private handleError(error: any): Promise<any> {
  console.error('An error occurred', error);
  return Promise.reject(error.message || error);
}

Configuring Request Headers

Add custom headers, such as Content-Type or authentication tokens, to requests.

import { HttpHeaders } from '@angular/common/http';

postData(data: any) {
  const headers = new HttpHeaders({ 'Content-Type': 'application/json' });
  return this.http.post('https://api.example.com/data', data, { headers });
}

Using JSON Data

HttpClient automatically handles JSON serialization and deserialization.

getData() {
  return this.http.get<any>('https://api.example.com/data');
}

postData(data: any) {
  return this.http.post<any>('https://api.example.com/data', data);
}

Uploading Files

Use FormData to upload files.

uploadFile(file: File) {
  const formData = new FormData();
  formData.append('file', file, file.name);

  return this.http.post('https://api.example.com/upload', formData);
}

Testing HttpClient

Use HttpClientTestingModule to mock HttpClient in unit tests.

import { HttpClientTestingModule } from '@angular/common/http/testing';

beforeEach(() => {
  TestBed.configureTestingModule({
    imports: [HttpClientTestingModule],
    providers: [ApiService]
  });
});

Advanced Topics

  • Interceptors: Use HttpInterceptor to intercept and modify requests or responses.
  • Canceling Requests: Use AbortController to cancel ongoing HTTP requests.
  • Response Types: Specify response types like text, json, or blob.

Signals

In Angular, “signals” primarily relate to RxJS, a reactive programming library providing Observables and Observers. Since Angular 14, a new reactive programming tool called Signals was introduced, offering a simpler, more intuitive state management approach. Signals are observable, updatable data structures for storing and sharing application state.

Signal Basics

Signals are reactive references that can be subscribed to and updated anywhere. They always have a current value, and subscribers are notified of changes.

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

class App {
  private _count = signal(0);

  get count() {
    return this._count();
  }

  increment() {
    this._count.set(this.count + 1);
  }
}

Creating Signals

Create a signal with the signal function, passing an initial value.

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

const name = signal('Alice');

Accessing and Updating Signals

Access a signal’s value by calling the signal function and update it with .set.

name(); // Get current value
name.set('Bob'); // Update value

Subscribing to Signals

Subscribe to signal changes with .subscribe.

name.subscribe(value => {
  console.log(`Name changed to ${value}`);
});

Signals and Components

Use signals as component properties, exposing them to templates.

import { Component } from '@angular/core';
import { signal } from '@angular/core';

@Component({
  selector: 'app-root',
  template: `
    <h1>Welcome, {{ name() }}</h1>
    <button (click)="changeName()">Change Name</button>
  `
})
export class AppComponent {
  name = signal('Alice');

  changeName() {
    this.name.set('Bob');
  }
}

Signals and Services

Signals in services enable state sharing across components.

// services/state.service.ts
import { Injectable } from '@angular/core';
import { signal } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class StateService {
  public name = signal('Alice');
}

Using the Signal in a Component:

// components/app.component.ts
import { Component } from '@angular/core';
import { StateService } from './services/state.service';

@Component({
  selector: 'app-root',
  template: `
    <h1>Welcome, {{ stateService.name() }}</h1>
    <button (click)="stateService.name.set('Bob')">Change Name</button>
  `
})
export class AppComponent {
  constructor(public stateService: StateService) {}
}

Signals and Forms

Signals can be used for two-way binding in form controls.

// components/form.component.ts
import { Component } from '@angular/core';
import { signal } from '@angular/core';

@Component({
  selector: 'app-form',
  template: `
    <input [(ngModel)]="username">
  `
})
export class FormComponent {
  username = signal('');
}

Signals and Routing

Signals can respond to route changes, such as updating the page title.

// app.component.ts
import { Component } from '@angular/core';
import { Router, NavigationEnd } from '@angular/router';
import { filter, map } from 'rxjs/operators';
import { signal } from '@angular/core';

@Component({
  selector: 'app-root',
  template: `
    <title>{{ title() }}</title>
  `
})
export class AppComponent {
  title = signal('Home');

  constructor(private router: Router) {
    this.router.events.pipe(
      filter(event => event instanceof NavigationEnd),
      map(() => this.router.url)
    ).subscribe(url => {
      if (url === '/about') {
        this.title.set('About Us');
      } else {
        this.title.set('Home');
      }
    });
  }
}

Signals and Performance Optimization

Signals optimize performance by avoiding unnecessary renders when their values remain unchanged.

Signals and Testing

Signals can be tested by simulating state changes with .set, enabling predictable and isolated tests.

it('should change name', () => {
  const stateService = TestBed.inject(StateService);
  stateService.name.set('Charlie');
  expect(stateService.name()).toBe('Charlie');
});
Share your love