API Client Service — Typed HttpClient Service with Error Handling

📋 Table of Contents
  1. ApiService Base Class
  2. Common Mistakes

A well-structured Angular API client service acts as the single boundary between the application and the HTTP layer — centralising base URL configuration, authentication headers, request/response transformation, and error handling. Building it as a base class that domain services extend keeps domain services clean (no HTTP boilerplate) while ensuring consistent behaviour across all API calls. The error handling layer translates HTTP status codes into typed application errors that the UI can handle meaningfully.

ApiService Base Class

import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams, HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, map }        from 'rxjs/operators';
import { APP_CONFIG }             from '@core/tokens/app-config.token';
import { NotificationService }    from '@core/services/notification.service';
import { Router }                 from '@angular/router';

export class ApiError {
  constructor(
    public status:   number,
    public message:  string,
    public errors:   Record<string, string[]> = {},
    public original: HttpErrorResponse | null  = null,
  ) {}
}

@Injectable({ providedIn: 'root' })
export class ApiService {
  protected readonly http    = inject(HttpClient);
  private   readonly config  = inject(APP_CONFIG);
  private   readonly notify  = inject(NotificationService);
  private   readonly router  = inject(Router);

  protected url(path: string): string {
    return `${this.config.apiUrl}${path}`;
  }

  protected get<T>(path: string, params?: Record<string, any>): Observable<T> {
    const httpParams = params
      ? new HttpParams({ fromObject: params })
      : undefined;
    return this.http.get<T>(this.url(path), { params: httpParams })
      .pipe(catchError(err => this.handleError(err)));
  }

  protected post<T>(path: string, body: unknown): Observable<T> {
    return this.http.post<T>(this.url(path), body)
      .pipe(catchError(err => this.handleError(err)));
  }

  protected put<T>(path: string, body: unknown): Observable<T> {
    return this.http.put<T>(this.url(path), body)
      .pipe(catchError(err => this.handleError(err)));
  }

  protected delete<T>(path: string): Observable<T> {
    return this.http.delete<T>(this.url(path))
      .pipe(catchError(err => this.handleError(err)));
  }

  private handleError(err: HttpErrorResponse): Observable<never> {
    switch (err.status) {
      case 0:   // network error / CORS
        this.notify.error('Network error. Check your connection.');
        break;
      case 401:
        this.router.navigate(['/auth/login'],
          { queryParams: { returnUrl: this.router.url } });
        break;
      case 403:
        this.notify.error('You do not have permission for this action.');
        break;
      case 404:
        // Let caller handle — not all 404s need a toast
        break;
      case 429:
        this.notify.warn('Too many requests. Please wait a moment.');
        break;
      case 500:
      case 502:
      case 503:
        this.notify.error('Server error. Please try again later.');
        break;
    }

    const validationErrors: Record<string, string[]> = err.error?.errors ?? {};
    const message = err.error?.title ?? err.error?.message ?? err.message;
    return throwError(() => new ApiError(err.status, message, validationErrors, err));
  }
}

// ── Domain service extending ApiService ───────────────────────────────────
@Injectable({ providedIn: 'root' })
export class PostsApiService extends ApiService {
  getPublished(page = 1, size = 10, category?: string):
      Observable<PagedResult<PostSummaryDto>> {
    return this.get('/api/posts', { page, size, ...(category && { category }) });
  }

  getBySlug(slug: string): Observable<PostDto> {
    return this.get(`/api/posts/${slug}`);
  }

  create(request: CreatePostRequest): Observable<PostDto> {
    return this.post('/api/posts', request);
  }

  update(id: number, request: UpdatePostRequest): Observable<PostDto> {
    return this.put(`/api/posts/${id}`, request);
  }

  deletePost(id: number): Observable<void> {
    return this.delete(`/api/posts/${id}`);
  }
}
Note: The base ApiService handles the cross-cutting concerns (error translation, notifications, navigation) while domain services (PostsApiService) define only the API contract. This separation means that adding a new endpoint is a single method in the domain service with no boilerplate. If the error handling strategy changes globally (e.g., adding retry logic for network errors), it is changed once in ApiService and applied to all services automatically.
Tip: Avoid making the handleError method show toasts for all error types — let the component decide whether a 404 warrants a notification or a redirect. The base service handles generic errors (network, 500, 401 redirect) because these have a consistent application-wide response. Domain-specific errors (409 conflict on a duplicate slug, 422 validation on form submission) should be re-thrown as ApiError for the component or form to handle in context. The component knows best whether to show a toast, display an inline error, or retry.
Warning: The 401 redirect in handleError can cause a redirect loop if the login page itself calls an API that returns 401. Guard against this by checking whether the current URL is already the login page before redirecting: if (!this.router.url.startsWith('/auth')) this.router.navigate(['/auth/login']). Also, the auth interceptor (Chapter 51) handles token refresh before errors reach the service layer — by the time handleError sees a 401, token refresh has already failed.

Common Mistakes

Mistake 1 — Duplicate HTTP logic in every service (inconsistent error handling)

❌ Wrong — each service has its own catchError handler; some show toasts, some throw, some swallow errors.

✅ Correct — centralise in base ApiService; domain services inherit consistent behaviour.

Mistake 2 — Showing error toasts for all status codes including form validation (poor UX)

❌ Wrong — 422 validation error shows a generic “Error” toast; user doesn’t know which fields to fix.

✅ Correct — rethrow 422 as ApiError with parsed errors dictionary; form component maps errors to controls.

🧠 Test Yourself

The API returns a 401 for an expired token. The auth interceptor (Chapter 60) handles token refresh and retries. If refresh also fails (refresh token expired), what error does the component see?