How to implement hierarchical dependency injection in AngularJs projects — pros, pitfalls and what to be aware of.
Picture taken from https://libreshot.com by Martin Vorel
Dependency Injection (DI)— Short Description
One form of state management in application, where the state is kept outside the scope of the current execution, and can be access by asking a global service to provide that state during object creation or execution. Multiple states can be kept by using different keys for each.
Dependency Injection in AngularJs
In AngularJs, dependency injection is provided as part of the framework.
One of the main mechanism for it is in the creation of components/directives and services. Services, or factory functions are registered in the framework DI manager and then those instances can be asked to be injected into components at creation time.
For example, a simple movie db application will be shown. Here we create our main app module.
const moviesApp = angular.module('movies', []);
The first service the authentication service that will provide us access to the server holding the movies information.
Notice that the service ask AngularJs for injection of $http HTTP client instance that AngularJs provide built in.
class AuthService {
static $inject = ['$http'];
private token: string;
constructor($http: IHttpService) {}
getToken() {
if (_.isNil(this.token)) {
this.token = $http.get('my-site.example.com/auth');
}
return this.token;
}
}
moviesApp.service('auth', AuthService);
Typesciprt/ES6 class and static inject transform to
function AuthService($http) {
this.$http = $http;
}
AuthService.$inject = ['$http'];
AngularJs looks for the $inject
marking on the service factory function and then:
Goes to the DI and ask for the states that corresponds to the required keys in the $inject array.
Activate the factory function, providing it with the requested injections.
Writing another service for our App — MoviesService
— we can make it depends on and require the previous service we built.
class MoviesService {
static $inject = ['$http', 'auth'];
movies = Promise.resolve([]);
constructor(
private $http: IHttpService,
private auth: AuthService,
) {}
getMovies() {
if (_.isNil(this.movies)) {
this.movies = this.auth.getToken()
.then((token) => {
return $http.get('my-site.example.com/movies', {
headers: {
Authorization: token,
},
});
});
}
return this.movies;
}
}
moviesApp.service('movies', MoviesService);
Having our MoviesService
, we can use it in a presentation component to show the movies on the page.
class MoviesList {
static $inject = ['movies'];
constructor(
movies: MoviesService
)
}
const MoviesListComponent = {
template: `
<h1>Movies</h1>
<ul>
<li ng-repeat="movie in ($ctrl.movies.movies | promiseAsync) track by movie.id">
{{ movie.name }} - {{ movie.year }} - {{ movie.rating }}
</li>
</ul>
`,
controller: MoviesList
};
moviesApp.component('moviesList', MoviesListComponent);
Here, the component ask for the movies
service to be injected into it on construction.
AngularJs does the same job it did for the services. It goes and collect the required dependencies instances from the DI manager, and then construct the component instance, providing it the wanted dependencies.
The Problem — One and Only One Level Of Injection
Lets say for instance that we want to two movies list components, each showing list of movies from a different site from each other.
<movies-list-my-site-a />
<movies-list-my-site-b />
In that scenario, it is difficult to build MovieListSiteA
, MovieListSiteB
components that resemble the logic of the original MovieList
component. If both require the same Movies service that require the same Auth service, they cannot have different auth token and different target servers.
The Auth in a sense is singleton one instance only per the key auth that is held by the main DI manager — the injector — of AngularJs.
A different but similar scenario is wanting to select multiple movies, and for each one, show a sub page that present list of details per that movies in multiple hierarchy of components. If we had CurrentSelectedMovie
service, it will be shared globally between all requesting component instances.
Angular/2 Solution For Required Nested Level of DI
In Angular/2, the rewritten DI provides a mechanism to register a service instance not only on the main root app, but also on each module and component level. Each component can ask for injection of dependency as before and also register services instances on its level.
@Component({
...
providers: [{ provide: AuthService }]
})
export class EastAndorMovieList
Meaning if for instance we have auth service provides by the root app module, a component can declare that it provides auth service under the auth key from now on for it self and its children components. A child component requesting injection of auth
service, will get the parent component override service and not the root module service.
AngularJs Solution For Required Nested Level of DI
Although AngularJs Does not support Nested Level of DI in its service/factory/component constructor injection mechanism, it does have some other interesting mechanism that can be used to implement hierarchical DI.
Enter require .
In AngularJs directives and components declaration, a require property can be specified that tell AngularJs to look up the dom tree and seek the specified controller. When found, inject it into the requesting directive.
An example of requiring ngModel directive controller on the same element:
moviesApp.directive('printout', ['$sce', function printout($sce) {
return {
restrict: 'A',
require: {
ngModel: ''
},
link: (scope, element, attrs, requireCtrls) {
requireCtrls.ngModel.$render = function() {
element.html($sce.getTrustedHtml(requireCtrls.ngModel.$viewValue || ''));
};
}
};
}]);
<div ng-model="$ctrl.myModel" printout />
Using component with require is with the same principle as components are type of directives.
angular.component('printout', {
template: `<div>{{ $ctrl.model | json:2 }}</div>,
require: {
ngModel: '',
},
controller: ['$sce', '$element', function controller($sce, $element) {
this.$onInit = () {
this.ngModel.$render = function() {
$element.html($sce.getTrustedHtml(this.ngModel.$viewValue || ''));
};
};
}],
});
Services can’t be defined and required hierarchically. Directives/Components can. What if we create a directive that act as a service?
AngularJs Service Directive
The auth
and movie
services refactored to a service directives, can look like this:
class AuthService {
static $inject = ['$http'];
private token: string;
constructor($http: IHttpService) {}
getToken() {
if (_.isNil(this.token)) {
this.token = $http.get('my-site.example.com/auth');
}
return this.token;
}
}
angular.directive('auth', [function auth() {
return {
restrict: 'A',
controller: AuthService,
};
}]);
/////////////////////////
class MoviesService {
static $inject = ['$http'];
movies = Promise.resolve([]);
constructor(
private $http: IHttpService,
) {}
getMovies() {
// require directives are avaiable when and after $onInit.
if (_.isNil(this.auth)) {
return [];
}
if (_.isNil(this.movies)) {
this.movies = this.auth.getToken()
.then((token) => {
return $http.get('my-site.example.com/movies', {
headers: {
Authorization: token,
},
});
});
}
return this.movies;
}
}
angular.directive('movies', [function movies() {
return {
restrict: 'A',
require: {
auth: '^',
},
controller: MoviesService,
};
}]);
When using them at a higher level in the dom tree:
<movies-app auth movies>
...
</movies-app>
Then in a component, they can be required and used.
class MoviesList {
}
const MoviesListComponent = {
template: `
<h1>Movies</h1>
<ul>
<li ng-repeat="movie in ($ctrl.movies.movies | promiseAsync) track by movie.id">
{{ movie.name }} - {{ movie.year }} - {{ movie.rating }}
</li>
</ul>
`,
require: {
movies: '^',
},
controller: MoviesList
};
moviesApp.component('moviesList', MoviesListComponent);
<movies-app auth movies>
<movies-list />
</movies-app>
Now, a new auth service can be defined at any given level on the auth key using a mediator, so if we wanted to override the main auth service, all that is needed to do is to change the auth directive service to return the desired service by the custom sub DI token for instance.
class AuthService {
static $inject = ['$http'];
private token: string;
constructor($http: IHttpService) {}
getToken() {
if (_.isNil(this.token)) {
this.token = $http.get('my-site.example.com/auth');
}
return this.token;
}
}
class EastAndorAuthService {
static $inject = ['$http'];
private token: string;
constructor($http: IHttpService) {}
getToken() {
if (_.isNil(this.token)) {
this.token = $http.get('east-andor.example.com/auth');
}
return this.token;
}
}
// using the same `auth` key to register EastAndoAuthService
angular.directive('auth', [function auth() {
return {
restrict: 'A',
controller: ['$attrs', '$injector', function controller($attrs, $injector) {
this.service = switchOn({
'': () => $injector.invoke(AuthService),
eastAndor: () => $injector.invoke(EastAndorAuthService),
}, $attrs.auth);
}],
};
}]);
<movies-app auth movies>
<movies-list /> <movies-list auth="east-andor" movies /> <div auth="volcan">
<movies-list movies />
</div>
</movies-app>
Using the $injector technique, the movies directives needs to adapt and use this.auth.service instead of this.auth .
Other simpler cases can adapt the same class to contains the different logic and use the attributes to customize it.
The service directive can even require other service directives. The movies service converted to service directive must require the auth service directive as it is no longer a regular service that can be injected into the constructor.
Points To Consider
Unlike Angular/2, only one directive per string token can be defined for the all app. Meaning the directives names are global. When wanting to return different behaviors, it is necessary to use a mediator logic techniques as seen above.
Unlike Angular/2, the using component can’t declare a service directive in its template, and require it. It can only require controllers directives that apply on its tag or above it.
This make it cumbersome to use as some solutions can be applied but neither is perfect.
Only directives/components can consume service directives,meaning if a service movies needs to use a service directive auth, that service needs to converted to service directive to use the require feature.
For point 2 for example, the component can use the directive inside its template, but then instead of require it, the directive can supply the service instance by executing & attribute expression that provide the component with the instance.
Example:
<div auth="east-andor" on-auth-service="$ctrl.auth = service"
A major drawback for this technique is that the service won’t be available even in the $onInit cycle.
Another solution is to create a mediator shell component in the original name that use the directives on it and call the original component which name has changed to include a prefix -base for example.
angular.component('movieList', {
template: `
<movie-list-base auth="easy-andor"
some-binding="$ctrl.someBinding
/>
`,
bindings: {
// same as original movie list
}
})
Summary
Whether this technique for hierarchical DI in AngularJs worth the hassle depended on how much gain the app can get from using hierarchical state.
But as seen, it is possible to use, and it is available as another technique in the arsenal of state management techniques in AngularJs.
Top comments (0)