Leveraging injection tokens to solve circular dependencies in shared libraries

ido mor
Yotpo Engineering

--

In the past year, my team and I have been busy building a shareable Angular library for the use of other developers in the organization.

In the process of doing so, we’ve faced lots of challenges of how to develop generic reusable services and components. We’ve also had to implement some interesting use cases of Angular’s injection tokens, in order to solve a variety of problems, like

  • circular dependency between our libraries
  • loose coupling between the hosting application and consumed library

If you’ve ever built, or are looking to build, an Angular library and you find yourself refactoring over and over again just to solve dependency issues, You came to the right place!.

Moreover, if you feel that you are well familiar with Angular’s injection tokens, but have the feeling that you can do more with them, then this post is definitely for you.

Ecosystem

As part of my work in Yotpo’s platform group, we aim to build capabilities for all of Yotpo’s current and future products. Those capabilities should reduce both the developers’ and customers’ learning curves while interacting with Yotpo’s components.

From the developers’ point of view, we aim to create Plug & Play building blocks, so that our developers will be able to increase their velocity.

For that purpose, we’ve built an Angular mono repo, managed by NX, we use semantic versioning, and publish our artifacts as npm packages to Jfrog.

To make our artifacts as generic as possible, we depend heavily on injection tokens to help the hosting app provide each lib with everything it has to know to perform its goal.

Plenty had been written about Angular Injection tokens, and if this technique is not familiar to you, then you should definitely read about it here. But the short version of Injection tokens (to me) is that this is a mechanism that gives you the ability to inject something other than an angular service, even something which is not a class.

Basic usage of injection tokens

Let’s examine how MeaningfulService exists in the core lib.

For this service to be generic, it is dependent on an injected configuration, so…

export interface MeaningfulServiceConfig {
meaningfulProp: string;
}

we expose an injection token for the MeaningfulModel…

import {InjectionToken} from '@angular/core';
import {MeaningfulServiceConfig} from './models/meaningful-service-config';

export const MEANINGFUL_CONFIG_INJECTION_TOKEN = new InjectionToken<MeaningfulServiceConfig>('MeaningfulServiceConfig');

and we consume this token in the constructor of the service:

import {Inject, Injectable} from '@angular/core';
import {MEANINGFUL_CONFIG_INJECTION_TOKEN} from '../meaningful-config-injection-token';
import {MeaningfulServiceConfig} from '../models/meaningful-service-config';

@Injectable({
providedIn: 'root'
})
export class MeaningfulService {

constructor(@Inject(MEANINGFUL_CONFIG_INJECTION_TOKEN) private config: MeaningfulServiceConfig) { }
}

This is all good for the core lib.

As you might have noticed, we have 2 more libraries that depend on the core library by using MeaningfulService.

import {Inject, Injectable} from '@angular/core';
import {MeaningfulService} from '../../../../core/src/lib/services/meaningful.service';
import {LIB_A_CONFIG_INJECTION_TOKEN} from '../lib-a-injection-token';
import {LibAConfig} from '../models/lib-a-config';

@Injectable()
export class ServiceAService {

constructor(private meaningfulService: MeaningfulService,
@Inject(LIB_A_CONFIG_INJECTION_TOKEN) private libAConfig: LibAConfig) { }

doSomething() {};
}

There are a few things to note here:

  • The service depends on both the core’s MeaningfulService and Lib-A injected configuration
  • The service is not provided in root, since it is part of a library that is designed to be lazy-loaded by the hosting application. Thus the service needs to be provided by an Angular module
export interface LibAConfig {
libAProp: string;
}
import {InjectionToken} from '@angular/core';
import {LibAConfig} from './models/lib-a-config';


export const LIB_A_CONFIG_INJECTION_TOKEN = new InjectionToken<LibAConfig>('LibAConfig');
@NgModule({
imports: [CommonModule],
providers: [ServiceAService,]
})
export class LibAModule {}

Now that our library is wrapped up, we can consume its Angular module in an Angular application. The module’s configuration also needs to be provided.

@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, LibAModule],
providers: [
{
provide: LIB_A_CONFIG_INJECTION_TOKEN,
useValue: LibAConfigProvider
}
],
bootstrap: [AppComponent],
})
export class AppModule {}
import {LibAConfig} from '../../../../libs/lib-a/src/lib/models/lib-a-config';

export const LibAConfigProvider: LibAConfig = {
libAProp: 'hello lib a'
}
export class AppComponent {
constructor(private service: ServiceAService) {
service.doSomething();
}
}

But oh no! We have a runtime error.

NullInjectorError — occurs due to a missing provider: ‘MeaningfulServiceConfig’

Ok, this one is easy to solve right?

We can provide the MeaningfulService configuration the same way we’d provided the Lib-A configuration, right? Here’s what I don’t like and wish to solve in this approach:

The hosting application that uses the Lib-A module needs to be familiar with Lib-A dependencies — MeaningfulService and its configuration. My main concern is that if tomorrow I replace the use of MeaningfulService in Lib-A with MeaningfulService2 (together with its own injection token), then this will be considered a breaking change and the hosting application will have to change its AppModule.

Moreover, as Lib-A expands and becomes more depended upon and requires more injection tokens, the hosting application would have to be extremely familiar with each dependency to provide its configuration.

With that in mind, let’s define the requirements for our solution.

Solution requirements

  1. Easy use

We would like our libraries to be as Plug & Play as possible, without the need to be familiar with all the injection tokens the library depends on.

2. Cognitive load reduction for library maintainers

As you may have guessed, the provided example is just a small part of a much bigger monorepo that is being maintained by a large number of developers.

Adding capabilities to the repo should be as easy as possible, without the need to be deeply invested in knowing each library’s dependencies. If I were to put this requirement into a clear statement:

I would prefer my code to break while compiling, not while testing, and most certainly not while being hosted in an application.

With those requirements in mind, let’s propose a solution.

Proposed solution

We are looking for a way to both hide the MeaningfulService within the Lib-A module, and to ensure that changing the MeaningfulService Config Model will fail on compilation.

Let’s update the Lib-A config model:

We are encapsulating the MeaningfulService Config Model within the Lib-A configuration model, thus making sure that if the Meaningful Config Model changes, we’ll get a compilation error (requirement 2).

import {MeaningfulServiceConfig} from '../../../../core/src/lib/models/meaningful-service-config';

export interface LibAConfig {
libAProp: string;
meaningfulServiceConfig: MeaningfulServiceConfig
}

We are not there yet though, we still need one more change in the Lib-A module.

We are building and providing the Meaningful config injection token using the Lib-A config injection token, thus creating a “chinese wall” between the hosting application and the lib’s dependencies. (requirement 1).

@NgModule({
imports: [CommonModule],
providers: [
ServiceAService,
{
provide: MEANINGFUL_CONFIG_INJECTION_TOKEN,
useFactory: (libAConfig: LibAConfig) => {return libAConfig.meaningfulServiceConfig},
deps: [LIB_A_CONFIG_INJECTION_TOKEN]
}
]
})
export class LibAModule {}

Ok, now we have a compilation error — seems we are on the right path.

Compilation error — Indication that our LibAConfig is missing the ‘meaningfulServiceConfig’ property

Simple, let’s also add the proper configuration for meaningfulServiceConfig in our application.

export const LibAConfigProvider: LibAConfig = {
libAProp: 'hello lib a',
meaningfulServiceConfig: {
meaningfulProp: 'hello MeaningfulService!'
}
}

All done! No compilation error and our app is up and running!

Solution testing

Let’s see if we’ve really provided a proper solution for the given requirements.

Say that I was to add a required property to MeaningfulServiceConfig,

export interface MeaningfulServiceConfig {
meaningfulProp: string;
meaningfulProp1: string;
}

we can see right away that we have a compilation error! That’s great!

Now if I wish to add another dependency to Lib-A, which also requires some injection token(s), I’ll use the same approach and encapsulate the new injection token in the Lib-A config model, thus hiding all the injection tokens from our application.

Takeaways

Your key takeaway should be that there is much more to injection tokens than meets the eye. They provide a great level of flexibility, together with generalization and clear boundaries.

Generic code is always hard to implement, but with the right technical tools, your implementation might be easier than you think. I believe that Angular’s injection token mechanism is a tool you must have in your toolbox.

Useful links

git repo with code examples: https://github.com/IdoMor/Angular-Injection-token-example

Angular Injection token: https://angular.io/api/core/InjectionToken

NX: https://nx.dev/

--

--