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}`);
}
}
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.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.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.