DEV Community

Cover image for Stop repeating HttpClient boilerplate in Angular
Rene Arias
Rene Arias

Posted on

Stop repeating HttpClient boilerplate in Angular

Set your API base URL and headers once with provideApi(), then inject(ApiService) anywhere. A tiny typed wrapper for Angular 17+.

Every Angular app that talks to a REST API ends up with the same few lines in every service: read the base URL from the environment, build the headers, glue the URL together, call HttpClient. Then the app grows, a second backend shows up, and those lines multiply.

I got tired of writing them, so years ago I wrote a small wrapper for my own apps. I just released version 3.0 of it, rebuilt for modern Angular: @arxis/api.

The problem

This is what a typical service looks like with HttpClient:

@Injectable({ providedIn: 'root' })
export class UserService {
  private readonly http = inject(HttpClient);
  private readonly baseUrl = environment.apiUrl;
  private readonly headers = new HttpHeaders({ 'X-Api-Key': environment.apiKey });

  getUsers() {
    return this.http.get<User[]>(`${this.baseUrl}/users`, { headers: this.headers });
  }

  createUser(body: CreateUserDto) {
    return this.http.post<User>(`${this.baseUrl}/users`, body, { headers: this.headers });
  }
}
Enter fullscreen mode Exit fullscreen mode

Only one part of each method is about users: 'users'. Everything else is plumbing, repeated in every service.

Configure the API once

npm install @arxis/api
Enter fullscreen mode Exit fullscreen mode

Tell the app where your API lives and which headers go with every request:

// app.config.ts
import { provideHttpClient } from '@angular/common/http';
import { provideApi } from '@arxis/api';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(),
    provideApi({
      url: environment.apiUrl,
      globalHeaders: { 'X-Api-Key': environment.apiKey },
    }),
  ],
};
Enter fullscreen mode Exit fullscreen mode

Now each service only describes its endpoints:

import { ApiService } from '@arxis/api';

@Injectable({ providedIn: 'root' })
export class UserService {
  private readonly api = inject(ApiService);

  getUsers() {
    return this.api.get<User[]>('users');
  }

  createUser(body: CreateUserDto) {
    return this.api.post<User>('users', body);
  }
}
Enter fullscreen mode Exit fullscreen mode

ApiService has get, post, put, patch and delete. They're typed, they accept query params as a plain object, and they have the same observe: 'response' and observe: 'events' overloads as HttpClient:

api.get<User[]>('users', { role: 'admin' });                      // GET /users?role=admin
api.get<User[]>('users', null, { observe: 'response' });          // full HttpResponse
api.post('upload', formData, { observe: 'events', reportProgress: true });
Enter fullscreen mode Exit fullscreen mode

It's still your HttpClient

@arxis/api doesn't create its own HTTP stack. It uses the HttpClient you configure, so everything you already have keeps working: interceptors, withFetch(), server-side rendering and HttpTestingController.

For example, an auth token that changes while the app runs belongs in an interceptor, and it applies to every ApiService request:

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(AuthService).token();
  return next(token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req);
};

// app.config.ts
provideHttpClient(withInterceptors([authInterceptor])),
provideApi({ url: environment.apiUrl }),
Enter fullscreen mode Exit fullscreen mode

More than one backend

Extend ApiService once per API and pass its config to super():

@Injectable({ providedIn: 'root' })
export class BillingApiService extends ApiService {
  constructor() {
    super({ url: environment.billingUrl });
  }
}
Enter fullscreen mode Exit fullscreen mode

Or give a lazy-loaded part of the app its own base URL:

{
  path: 'admin',
  providers: [provideApi({ url: environment.adminApiUrl })],
  loadChildren: () => import('./admin/admin.routes'),
}
Enter fullscreen mode Exit fullscreen mode

Testing

Since requests go through Angular's HttpClient, you test your services the usual way:

TestBed.configureTestingModule({
  providers: [
    provideHttpClient(),
    provideHttpClientTesting(),
    provideApi({ url: 'https://api.example.com' }),
  ],
});

TestBed.inject(UserService).getUsers().subscribe();
TestBed.inject(HttpTestingController).expectOne('https://api.example.com/users').flush([]);
Enter fullscreen mode Exit fullscreen mode

What's new in 3.0

  • Works with Angular 17 through 22.
  • ApiService is providedIn: 'root' and uses inject().
  • provideApi() returns EnvironmentProviders, like provideHttpClient() and provideRouter().
  • If you forget provideApi(), the error tells you what to add.
  • About 1 KB minified and gzipped.

Coming from 1.x? The migration guide has the six steps.

Try it

If it saves you some boilerplate, a star on GitHub helps other Angular developers find it. Issues and ideas are welcome too.

Top comments (0)