Updated on

While sending HTTP requests to our server, we need to use the Angular HttpClient. Of course, we may handle all the HTTP requests from every component and process the response as well, but it is not a good practice. It is much better to make one repository for our requests and then send the request URI to that repository. The repository should take care of the rest.

For the complete navigation and all the basic instructions of the Angular series, check out: Introduction of the Angular series.

This part continues from Angular Routing and Navigation Menu, where the application got its menu and its routes.

To download the source code for this article, you can visit the AngularHttpClientAndServices folder in our GitHub repository. The source code for the whole Angular series is here.
The Web API this client calls is the finished project of our ASP.NET Core Web API series: the UsingRepositoryForWriteRequests folder. We start it with the http launch profile, so it listens on http://localhost:5000.

So, let’s start with the environment files first.

How Do Angular Environment Files Work?

An Angular environment file is a plain TypeScript module of configuration values, and the build swaps one file for another depending on which configuration we build.

The swap is not magic, and nothing reads these files at runtime. angular.json lists a fileReplacements entry for a build configuration, the compiler substitutes one file path for the other, and the application only ever imports the one path.

The CLI does not create the folder for us. ng generate environments writes src/environments/environment.ts alongside environment.development.ts and adds the replacement entry to angular.json.

The direction catches people out. environment.ts is the base file, and it is what a production build uses. The development configuration is the one that replaces it, with environment.development.ts.

Everything the application imports comes from the base path. Code always writes import { environment } from '../environments/environment', never the development file, because the whole point is that the import does not change.

Our client calls http://localhost:5000 while we develop it and a public address once it is deployed, so let’s generate the pair of files that holds those two values:

ng generate environments

The CLI creates the folder, both files and the replacement entry:

CREATE src/environments/environment.ts (31 bytes)
CREATE src/environments/environment.development.ts (31 bytes)
UPDATE angular.json (2243 bytes)

Let’s start with environment.ts, the base file that a production build uses:

export const environment = {
  urlAddress: 'http://www.accountowner.com'
};

Then let’s modify the environment.development.ts file, which the build substitutes for the base one while we work locally:

export const environment = {
  urlAddress: 'http://localhost:5000'
};

Now we are going to create a service, which we can use to get the valid environment urlAddress.

Let’s create a new environment-url.service.ts file:

ng g service shared/services/environment-url --type=service

This command creates the shared/services folders and the service file inside them:

CREATE src/app/shared/services/environment-url.service.ts

The --type=service flag is what puts service in the file name and Service at the end of the class name. Without it, the CLI writes environment-url.ts holding a class called EnvironmentUrl, and every later part of this series imports EnvironmentUrlService.

Let’s continue our work with the environment files by modifying the environment-url.service.ts file:

import { Service } from '@angular/core';

import { environment } from '../../../environments/environment';

@Service()
export class EnvironmentUrlService {
  urlAddress: string = environment.urlAddress;
}

The urlAddress property takes the value from whichever environment file the build used, and that choice is made in angular.json:

"configurations": {
  "production": {
    "budgets": [...],
    "outputHashing": "all"
  },
  "development": {
    "optimization": false,
    "extractLicenses": false,
    "sourceMap": true,
    "fileReplacements": [
      {
        "replace": "src/environments/environment.ts",
        "with": "src/environments/environment.development.ts"
      }
    ]
  }
},
"defaultConfiguration": "production"

Notice which configuration carries the replacement. The production configuration has no fileReplacements at all, and the build target’s defaultConfiguration is production, so a bare ng build reads environment.ts untouched. It is ng serve, which builds with the development configuration, that swaps in the other file. Angular’s Build environments guide says the same thing about that base file: “The project’s src/environments/ directory contains the base configuration file, environment.ts, which provides the default configuration for production.”

If we have worked with the older model, where environment.prod.ts replaced environment.ts for production, nothing about our two commands changes: ng serve still talks to localhost and ng build still ships the public address. Only the direction of the replacement is inverted.

A production build is where that swap becomes visible, and our guide on publishing an Angular application with ASP.NET Core takes it from there.

What Is an Angular Service?

An Angular service is an ordinary class that holds logic a component should not hold itself, and Angular’s injector creates it and hands it to whoever asks.

Registration is one decorator. ng generate service writes @Service(), which provides the class in the root injector by itself. One shared instance then serves the whole application, created the first time something asks for it.

Asking is inject(). A component writes private repo = inject(OwnerRepositoryService) and gets that instance, never calling new. Older code uses @Injectable({ providedIn: 'root' }) with a constructor parameter, and both still work.

Sharing is the point. A service holding fetched data, a cache, or an HTTP wrapper should be shared, because two components asking for it get the same object and therefore the same state.

The test is simple. If a second component would need the same code, or if the component’s class is doing work that has nothing to do with what it renders, it belongs in a service.

How Do We Wrap Angular HttpClient in a Service?

Components should not call HttpClient directly. Wrapping the calls in a service gives every component one place to ask for data and one place to change when the API does.

HttpClient is injected into the service, and the service is injected into the components. Registration is one provider: provideHttpClient() in app.config.ts.

The wrapper’s job is to turn a short route into a full URL and return the observable unchanged. getOwners('api/owner') combines the route with the base address from the environment file and calls http.get, so no component ever writes a hostname.

The generic type argument is the second reason to wrap. http.get<Owner[]>(url) tells TypeScript what the response holds, so every component consuming the service gets a typed result instead of a bare object.

Nothing is sent yet. These methods build observables and return them, and no request leaves the browser until something subscribes.

One request passes through three of our own classes before it reaches the network.

A component calls OwnerRepositoryService, which combines the route with the base URL from EnvironmentUrlService and calls HttpClient, which sends the request to the Web API. The environment file is chosen at build time.

First, we register HttpClient for the whole application in the app.config.ts file:

import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes),
    provideHttpClient()
  ]
};

Older applications import HttpClientModule into an NgModule instead. That module is deprecated, and provideHttpClient() is what replaces it, so there is no imports array to touch here.

After that, let’s create a service file and name it owner-repository.service.ts. We are going to place it in the same folder in which the environment service resides. In this service, we are going to create GET, POST, PUT, and DELETE requests for the owner entity from our server API:

ng g service shared/services/owner-repository --type=service

After the file creation, we are going to create a new _interfaces folder under the app folder, and add a new owner.model.ts file:

export interface Owner {
  id: string;
  name: string;
  dateOfBirth: Date;
  address: string;
}

One thing about that interface is worth knowing before we use it. The http.get<Owner[]>() call constructs nothing: it parses the JSON response and asserts the type we gave it. JSON has no date type, so dateOfBirth arrives as a string while TypeScript believes it is a Date, and the first call to owner.dateOfBirth.getFullYear() fails at runtime with an error the compiler approved. There are two honest fixes: type the field as a string and convert it where we display it, or map the response and construct real Date objects.

Once we have our interface in place, we can modify the repository file:

import { HttpClient } from '@angular/common/http';
import { Service, inject } from '@angular/core';

import { Owner } from '../../_interfaces/owner.model';
import { EnvironmentUrlService } from './environment-url.service';

@Service()
export class OwnerRepositoryService {
  private http = inject(HttpClient);
  private envUrl = inject(EnvironmentUrlService);

  public getOwners(route: string) {
    return this.http.get<Owner[]>(this.createCompleteRoute(route, this.envUrl.urlAddress));
  }

  public createOwner(route: string, owner: Owner) {
    return this.http.post<Owner>(this.createCompleteRoute(route, this.envUrl.urlAddress), owner);
  }

  public updateOwner(route: string, owner: Owner) {
    return this.http.put(this.createCompleteRoute(route, this.envUrl.urlAddress), owner);
  }

  public deleteOwner(route: string) {
    return this.http.delete(this.createCompleteRoute(route, this.envUrl.urlAddress));
  }

  private createCompleteRoute(route: string, envAddress: string) {
    return `${envAddress}/${route}`;
  }
}

Repository Code Explanation

Let’s explain this code.

First, we ask for the Angular HttpClient and our EnvironmentUrlService with the inject() function. Then we write the functions that wrap our requests. The getOwners function is a wrapper for the GET request. It accepts the route parameter of the string type (api/owner) and combines it with the address from the environment file, so in a development build the request goes to http://localhost:5000/api/owner, which is exactly where our Web API listens.

The second function, createOwner, is a wrapper for a POST request. It builds the same complete route and additionally receives a body, the entity we are creating. We set no header here, because HttpClient sends Content-Type: application/json by itself whenever the body is an object. If we ever need another header, we pass an HttpHeaders object in the options argument.

The updateOwner function is pretty much the same as the createOwner function, except that it sends the PUT request. Lastly, the deleteOwner function is a wrapper for the DELETE request, which accepts a route like api/owner/id. Both of these functions are missing a strongly typed HTTP method because we don’t expect any object as a response from the server. If everything goes well, we are going to get a 204 as a response without a response body.

Our repository wraps four of the methods HttpClient exposes, and each one of them returns an observable rather than a result:

MethodTypical callReturnsSends a body
GEThttp.get<T>(url)Observable<T>no
POSThttp.post<T>(url, body)Observable<T>yes
PUThttp.put<T>(url, body)Observable<T>yes
PATCHhttp.patch<T>(url, body)Observable<T>yes
DELETEhttp.delete<T>(url)Observable<T>not by default
HEADhttp.head<T>(url)Observable<T>no
anyhttp.request<T>(method, url, options)Observable<T>depends on options

Adding observe: 'response' to the options changes the return type to Observable<HttpResponse<T>>, which is how we read status codes and headers instead of just the body.

The Subscription on the HTTP Calls

These wrapper functions need a subscription in order to work. In this post, we are only creating a repository with the HTTP calls. But as soon as we start creating our pages, we are going to use the subscription.

For now, we are just going to show you one example of a subscription:

owners = signal<Owner[]>([]);

private repo = inject(OwnerRepositoryService);

private consumeGetFromRepository = () => {
  this.repo.getOwners('api/owner')
  .subscribe(own => {
    this.owners.set(own);
  })
}

As you may notice, we are calling the getOwners function from the repository, but that function won’t be executed until we call the subscribe function. Angular’s HttpClient guide puts the same order of events in one sentence: “Each method returns an RxJS Observable which, when subscribed, sends the request and then emits the results when the server responds.” The result from the response is going to arrive in the own parameter, and we hold it in a signal so the template re-renders as soon as it lands. Both signal and inject come from @angular/core, and the next part explains that signal beside the list it renders.

There is nothing to unsubscribe from here. An HttpClient observable emits one response and completes, while a subscription to a long-lived observable is the one that needs cleaning up.

This example handles the success path and nothing else. Handling the failure path is part 11’s subject.

Current Angular can also hand us an HTTP response as a signal instead of a subscription, with toSignal from @angular/core/rxjs-interop or with the httpResource function. We stay with subscribe because the parts that follow build on it.

Conclusion

Excellent, now we have our repository prepared and we are ready to create components, which are going to use these repository functions to show results in a browser.

In the next part, Angular Lazy Loading: Load Routes on Demand, we load a feature area on demand and show the fetched data on the page.

Tested with Angular 22.1.5, Angular CLI 22.1.7, ngx-bootstrap 22.0.0, Bootstrap 5.3.8, TypeScript 6.0.3 and Node 24.