A complete typed API client service encapsulates all HTTP communication for a domain, exposes a clean Observable-based API, and handles the mapping between HTTP responses and application models. When structured correctly, components never touch HttpClient directly — they only call service methods. This keeps HTTP concerns (URLs, headers, params, error parsing) in one place, making the application easier to test and evolve as the API changes.
Complete PostsApiService
@Injectable({ providedIn: 'root' })
export class PostsApiService {
private http = inject(HttpClient);
private baseUrl = inject(API_BASE_URL);
private url = (path: string) => `${this.baseUrl}/api${path}`;
// ── READ ──────────────────────────────────────────────────────────────
getPublished(params: PostQueryParams = {}): Observable<PagedResult<PostSummaryDto>> {
return this.http.get<PagedResult<PostSummaryDto>>(this.url('/posts'), {
params: this.buildParams(params),
});
}
getBySlug(slug: string): Observable<PostDto> {
return this.http.get<PostDto>(this.url(`/posts/by-slug/${slug}`));
}
getById(id: number): Observable<PostDto> {
return this.http.get<PostDto>(this.url(`/posts/${id}`));
}
// ── WRITE ─────────────────────────────────────────────────────────────
create(request: CreatePostRequest): Observable<PostDto> {
return this.http.post<PostDto>(this.url('/posts'), request);
}
update(id: number, request: UpdatePostRequest): Observable<PostDto> {
return this.http.patch<PostDto>(this.url(`/posts/${id}`), request);
}
publish(id: number): Observable<PostDto> {
return this.http.post<PostDto>(this.url(`/posts/${id}/publish`), {});
}
delete(id: number): Observable<void> {
return this.http.delete<void>(this.url(`/posts/${id}`));
}
// ── Error parsing — ValidationProblemDetails → Angular form errors ────
parseValidationErrors(error: HttpErrorResponse): Record<string, string[]> {
if (error.status === 400 && error.error?.errors) {
// ASP.NET Core ValidationProblemDetails format:
// { errors: { "title": ["Required"], "slug": ["Already taken"] } }
return error.error.errors as Record<string, string[]>;
}
return {};
}
// ── Private helpers ───────────────────────────────────────────────────
private buildParams(query: PostQueryParams): HttpParams {
let params = new HttpParams();
if (query.page) params = params.set('page', query.page);
if (query.size) params = params.set('size', query.size);
if (query.search) params = params.set('search', query.search);
if (query.category) params = params.set('category', query.category);
if (query.sort) params = params.set('sort', query.sort);
return params;
}
}
// ── Component using the service with error parsing ────────────────────────
@Component({ standalone: true, template: '...' })
export class PostFormComponent {
private api = inject(PostsApiService);
form = inject(FormBuilder).nonNullable.group({
title: ['', Validators.required],
slug: ['', Validators.required],
body: ['', Validators.required],
});
isSaving = signal(false);
serverError = signal('');
onSubmit(): void {
if (this.form.invalid) return;
this.isSaving.set(true);
this.api.create(this.form.getRawValue()).subscribe({
next: post => {
this.isSaving.set(false);
// Navigate to the new post
},
error: (err: HttpErrorResponse) => {
this.isSaving.set(false);
const fieldErrors = this.api.parseValidationErrors(err);
// Set server-side errors on individual form controls
Object.entries(fieldErrors).forEach(([field, messages]) => {
this.form.get(field)?.setErrors({ serverError: messages[0] });
});
if (!Object.keys(fieldErrors).length) {
this.serverError.set(err.error?.title ?? 'An error occurred.');
}
},
});
}
}
parseValidationErrors() method bridges the gap between ASP.NET Core’s ValidationProblemDetails error format and Angular’s reactive form error model. The server returns { "errors": { "title": ["Required"], "slug": ["Slug taken"] } }; the component maps these to form control errors using form.get(field)?.setErrors({ serverError: message }). The template then displays these errors with @if (form.controls.title.hasError('serverError')) { ... }. This provides the same field-level validation UX for both client-side and server-side errors.private url(path: string)) for URL construction so the base URL is set once and all endpoint methods use the helper. If the API URL or version prefix changes, one change updates all endpoints. Also consider using route constants: const API_ROUTES = { posts: '/posts', postBySlug: (slug: string) => '/posts/by-slug/' + slug } — this makes typos in URL strings a compile-time error when using TypeScript strict mode.HttpClient from a service. If you have public http = inject(HttpClient) on a service, components can bypass the service’s error handling, caching, and URL construction by calling service.http.get(...) directly. Keep HttpClient private and expose only typed Observable-returning methods. This ensures all HTTP requests go through the service’s consistent logic.Common Mistakes
Mistake 1 — Hardcoding API URLs in multiple services (fragile, hard to update)
❌ Wrong — http.get('https://api.blogapp.com/api/posts') repeated in every service method.
✅ Correct — inject API_BASE_URL token once per service; use a url(path) helper for consistency.
Mistake 2 — Ignoring 400 validation errors from the server (form shows no field errors)
❌ Wrong — catching all errors with a generic message; specific field validation errors from the API never shown.
✅ Correct — parse error.error.errors and set them on the corresponding form controls.