Interfaces in Angular dependency injection

October 15th 2021 Angular Dependency Injection

Angular's dependency injection makes it really easy to inject services into components and other services. In most cases, that's all we need. But what if we need more than one implementation of the same service contract? To accomplish this in strongly typed languages, we typically create interfaces as contracts that can be implemented by multiple services. When an instance of the interface is requested, the correct implementation is injected based on the dependency injection configuration. In Angular, this does not work.

A typical service for which we need an alternative implementation is a client-side proxy for a remote REST service. This can be useful not only in tests (where we can use mock objects or spies), but also during development. For example, when implementing user interface design, it can be difficult to get the right data for all edge cases if we do not have full control over the data source. A local mock implementation of the real web service makes it much easier to get exactly the data we need for each edge case.

Let us look at a trivial example of a client proxy for GitHub REST API to see how this can be done with Angular. The following service retrieves issues for a GitHub repository:

@Injectable({
  providedIn: "root",
})
export class RemoteApiService {
  constructor(private httpClient: HttpClient) {}

  public getIssues(owner: string, repo: string): Observable<Issue[]> {
    return this.httpClient.get<Issue[]>(
      `https://api.github.com/repos/${owner}/${repo}/issues`
    );
  }
}

To retrieve the data on a page, we can inject RemoteApiService into a component and call its method there:

@Component({
  selector: "app-root",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.scss"],
})
export class AppComponent {
  public issues: Issue[] = [];

  constructor(remoteApiService: RemoteApiService) {
    remoteApiService
      .getIssues("dotnet", "runtime")
      .subscribe((issues) => (this.issues = issues));
  }
}

Before we can create an alternative mock implementation that we can inject in components instead of RemoteApiService, we need to define its contract. To do this, we can use an interface:

export interface ApiService {
  getIssues(owner: string, repo: string): Observable<Issue[]>;
}

To take advantage of TypeScript's type safety, RemoteApiService should explicitly implement the ApiService interface:

@Injectable({
  providedIn: "root",
})
export class RemoteApiService implements ApiService {
  // ...
}

We now want to inject ApiService into the component instead of RemoteApiService implementing it:

constructor(apiService: ApiService) {
  apiService
    .getIssues('dotnet', 'runtime')
    .subscribe((issues) => (this.issues = issues));
}

The last remaining step is to configure RemoteApiService as the service to inject when the ApiService interface is requested:

@NgModule({
  providers: [{ provide: ApiService, useClass: RemoteApiService }],
  // ...
})
export class AppModule {}

Unfortunately, this does not work. Typescript reports the following error:

'ApiService' only refers to a type, but is being used as a value here.

If you are not very familiar with TypeScript, the error probably does not tell you much. The reason this does not work is that the interface does not exist in the compiled code, so it can not serve as a token to identify the service. However, we can use an abstract class instead of a service without making any further changes to the code:

export abstract class ApiService {
  public abstract getIssues(owner: string, repo: string): Observable<Issue[]>;
}

With this change, the code compiles and seems to work as expected. However, there is still some hidden behavior that is probably not desired. Namely, the decorator for RemoteApiService still registers it with dependency injection:

@Injectable({
  providedIn: "root",
})
export class RemoteApiService implements ApiService {
  // ...
}

Therefore, both ApiService and RemoteApiService can now be requested for injection. RemoteApiService will be injected for both. But it will not be the same instance:

it("should get different instances for ApiService and RemoteApiService", () => {
  const apiService = TestBed.inject(ApiService);
  const remoteApiService = TestBed.inject(RemoteApiService);

  expect(remoteApiService).not.toBe(apiService);
});

To get the same instance for both, the dependency injection configuration can be changed slightly:

@NgModule({
  providers: [{ provide: ApiService, useExisting: RemoteApiService }],
  // ...
})
export class AppModule {}

But instead of doing this, it is better not to register RemoteApiService for injection. This can be achieved by changing its decorator:

@Injectable()
export class RemoteApiService implements ApiService {
  // ...
}

With this change, any attempt to inject RemoteApiService will fail with the following error message:

NullInjectorError: No provider for RemoteApiService!

This will prevent RemoteApiService from being accidentally injected into a component instead of ApiService (this would mean that RemoteApiService will still be injected even if we change the registration for ApiService).

However, the default setup code for testing RemoteApiService also fails and needs to be extended with an explicit dependency injection configuration:

describe("RemoteApiService", () => {
  let service: ApiService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [{ provide: ApiService, useClass: RemoteApiService }],
    });
    service = TestBed.inject(ApiService);
  });

  // ...
});

So we are finally ready for an alternative implementation of ApiService that allows us to return exactly the data we need during development (and that we can change at will):

@Injectable()
export class MockApiService implements ApiService {
  constructor() {}

  public getIssues(owner: string, repo: string): Observable<Issue[]> {
    return of([{ number: 1, title: "Fake issue" }]);
  }
}

To use it temporarily instead of RemoteApiService, all we need to do is change the dependency injection configuration in the module:

@NgModule({
  providers: [{ provide: ApiService, useClass: MockApiService }],
  // ...
})
export class AppModule {}

When we no longer need the service to return certain predefined data, we can simply revert this change to use the real RemoteApiService again.

A sample project with all the code from this post is available in my GitHub repository. The individual commits correspond to the various steps in the development of this solution.

Angular services are self-registered for dependency injection by default. In most cases, this is fine. But when we need alternate implementations for a service, it's best to create an abstract class that serves as the service contract. The desired implementation to be injected for this contract can then be explicitly configured in the module.

Get notified when a new blog post is published (usually every Friday):

If you're looking for online one-on-one mentorship on a related topic, you can find me on Codementor.
If you need a team of experienced software engineers to help you with a project, contact us at Razum.
Copyright
Creative Commons License