Docs
/
Angular
Chapter 5

05 — Services & HTTP Client

Services in Angular

A service is a class with a focused purpose — data access, business logic, state, or utility functions. Components should be thin (presentation only); services handle the heavy lifting.

@Injectable({ providedIn: 'root' })
export class UserService {
  private apiUrl = inject(API_URL);
  private http = inject(HttpClient);

  getAll(): Observable<User[]> {
    return this.http.get<User[]>(`${this.apiUrl}/users`);
  }
}

Setting Up HttpClient

app.config.ts

import { provideHttpClient, withInterceptors, withFetch } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(
      withInterceptors([authInterceptor, errorInterceptor]),
      withFetch(),             // Use fetch API instead of XMLHttpRequest
    ),
  ],
};

CRUD with HttpClient

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

@Injectable({ providedIn: 'root' })
export class ProductService {
  private http = inject(HttpClient);
  private baseUrl = `${environment.apiUrl}/products`;

  // GET all (with query params)
  getAll(page = 1, limit = 10, search?: string): Observable<PaginatedResponse<Product>> {
    let params = new HttpParams()
      .set('page', page)
      .set('limit', limit);

    if (search) params = params.set('search', search);

    return this.http.get<PaginatedResponse<Product>>(this.baseUrl, { params });
  }

  // GET one
  getById(id: string): Observable<Product> {
    return this.http.get<Product>(`${this.baseUrl}/${id}`);
  }

  // POST
  create(data: CreateProductDto): Observable<Product> {
    return this.http.post<Product>(this.baseUrl, data);
  }

  // PUT (full replace)
  update(id: string, data: Product): Observable<Product> {
    return this.http.put<Product>(`${this.baseUrl}/${id}`, data);
  }

  // PATCH (partial update)
  patch(id: string, data: Partial<Product>): Observable<Product> {
    return this.http.patch<Product>(`${this.baseUrl}/${id}`, data);
  }

  // DELETE
  delete(id: string): Observable<void> {
    return this.http.delete<void>(`${this.baseUrl}/${id}`);
  }
}

Using Services in Components

With async Pipe (Preferred)

@Component({
  template: `
    @if (products$ | async; as products) {
      @for (p of products; track p.id) {
        <app-product-card [product]="p" />
      }
    } @else {
      <app-spinner />
    }
  `,
})
export class ProductListComponent {
  private productService = inject(ProductService);
  products$ = this.productService.getAll();
}

With Manual Subscribe

export class ProductListComponent implements OnInit {
  private productService = inject(ProductService);
  private destroyRef = inject(DestroyRef);

  products: Product[] = [];
  loading = true;
  error: string | null = null;

  ngOnInit() {
    this.productService.getAll()
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe({
        next: (res) => { this.products = res.data; this.loading = false; },
        error: (err) => { this.error = err.message; this.loading = false; },
      });
  }
}

HTTP Interceptors (Functional — Angular 15+)

Interceptors modify every HTTP request/response globally.

Auth Interceptor — Attach JWT Token

import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const authService = inject(AuthService);
  const token = authService.getToken();

  if (token) {
    const cloned = req.clone({
      setHeaders: { Authorization: `Bearer ${token}` },
    });
    return next(cloned);
  }

  return next(req);
};

Error Interceptor — Global Error Handling

import { HttpInterceptorFn } from '@angular/common/http';
import { catchError, throwError } from 'rxjs';

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const router = inject(Router);
  const toast = inject(ToastService);

  return next(req).pipe(
    catchError(error => {
      switch (error.status) {
        case 401:
          router.navigate(['/login']);
          break;
        case 403:
          toast.error('You do not have permission');
          break;
        case 404:
          toast.error('Resource not found');
          break;
        case 500:
          toast.error('Server error. Please try again later.');
          break;
        default:
          toast.error(error.error?.message ?? 'An error occurred');
      }
      return throwError(() => error);
    }),
  );
};

Loading Interceptor — Global Spinner

export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
  const loading = inject(LoadingService);

  loading.show();
  return next(req).pipe(
    finalize(() => loading.hide()),
  );
};

Retry Interceptor

export const retryInterceptor: HttpInterceptorFn = (req, next) => {
  return next(req).pipe(
    retry({
      count: 2,
      delay: (error, retryCount) => {
        if (error.status >= 400 && error.status < 500) {
          return throwError(() => error);   // Don't retry 4xx
        }
        return timer(1000 * retryCount);    // Exponential backoff
      },
    }),
  );
};

Register Interceptors

// app.config.ts
provideHttpClient(
  withInterceptors([
    authInterceptor,
    loadingInterceptor,
    retryInterceptor,
    errorInterceptor,   // Keep error handler last
  ]),
),

File Upload

upload(file: File): Observable<HttpEvent<UploadResponse>> {
  const formData = new FormData();
  formData.append('file', file, file.name);

  return this.http.post<UploadResponse>(`${this.baseUrl}/upload`, formData, {
    reportProgress: true,
    observe: 'events',
  });
}

// Usage — track progress
this.productService.upload(file).subscribe(event => {
  if (event.type === HttpEventType.UploadProgress && event.total) {
    this.progress = Math.round(100 * event.loaded / event.total);
  }
  if (event.type === HttpEventType.Response) {
    console.log('Upload complete:', event.body);
  }
});

Caching with Service

@Injectable({ providedIn: 'root' })
export class ProductService {
  private http = inject(HttpClient);
  private cache$ = new Map<string, Observable<Product>>();

  getById(id: string): Observable<Product> {
    if (!this.cache$.has(id)) {
      this.cache$.set(
        id,
        this.http.get<Product>(`/api/products/${id}`).pipe(
          shareReplay(1),                        // Cache the last emission
        ),
      );
    }
    return this.cache$.get(id)!;
  }

  invalidateCache(id: string) {
    this.cache$.delete(id);
  }
}

Type-Safe API Service Pattern

// models/api.model.ts
export interface ApiResponse<T> {
  data: T;
  message: string;
  statusCode: number;
}

export interface PaginatedResponse<T> {
  data: T[];
  total: number;
  page: number;
  totalPages: number;
}

// Generic base service
@Injectable({ providedIn: 'root' })
export class ApiService {
  private http = inject(HttpClient);
  private baseUrl = inject(API_URL);

  get<T>(path: string, params?: HttpParams): Observable<T> {
    return this.http.get<T>(`${this.baseUrl}${path}`, { params });
  }

  post<T>(path: string, body: unknown): Observable<T> {
    return this.http.post<T>(`${this.baseUrl}${path}`, body);
  }

  put<T>(path: string, body: unknown): Observable<T> {
    return this.http.put<T>(`${this.baseUrl}${path}`, body);
  }

  patch<T>(path: string, body: unknown): Observable<T> {
    return this.http.patch<T>(`${this.baseUrl}${path}`, body);
  }

  delete<T>(path: string): Observable<T> {
    return this.http.delete<T>(`${this.baseUrl}${path}`);
  }
}

Key Takeaways

  • Keep components thin — move HTTP, state, and business logic into services
  • Use provideHttpClient(withInterceptors([...])) to register functional interceptors
  • Use async pipe in templates to auto-manage subscriptions
  • Functional interceptors (Angular 15+) are simpler than class-based ones
  • Order of interceptors matters — auth first, error handler last
  • Use shareReplay(1) for simple caching; a Map<string, Observable> for per-entity caching
  • Always type your API responses — this.http.get<Product>(url) — never use any