Hierarchical Dependency Injection in AngularJs

elpddev

Eyal Lapid

Posted on January 6, 2021

Hierarchical Dependency Injection in AngularJs

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', []);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Typesciprt/ES6 class and static inject transform to

function AuthService($http) {
  this.$http = $http;
}
AuthService.$inject = ['$http'];
Enter fullscreen mode Exit fullscreen mode

AngularJs looks for the $inject marking on the service factory function and then:

  1. Goes to the DI and ask for the states that corresponds to the required keys in the $inject array.

  2. 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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 /> 
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 || ''));
      };
    }
  };
}]);
Enter fullscreen mode Exit fullscreen mode
<div ng-model="$ctrl.myModel" printout />
Enter fullscreen mode Exit fullscreen mode

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 || ''));
      };
    };
  }],
});
Enter fullscreen mode Exit fullscreen mode

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,
  };
}]);
Enter fullscreen mode Exit fullscreen mode

When using them at a higher level in the dom tree:

<movies-app auth movies>
   ...
</movies-app>
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
<movies-app auth movies>
   <movies-list />
</movies-app>
Enter fullscreen mode Exit fullscreen mode

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);
    }],
  };
}]);
Enter fullscreen mode Exit fullscreen mode
<movies-app auth movies>
   <movies-list />   <movies-list auth="east-andor" movies />   <div auth="volcan">
     <movies-list movies />
   </div>
</movies-app>
Enter fullscreen mode Exit fullscreen mode
  1. Using the $injector technique, the movies directives needs to adapt and use this.auth.service instead of this.auth .

  2. Other simpler cases can adapt the same class to contains the different logic and use the attributes to customize it.

  3. 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

  1. 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.

  2. 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.

  3. This make it cumbersome to use as some solutions can be applied but neither is perfect.

  4. 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"
Enter fullscreen mode Exit fullscreen mode

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
  }
})
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
elpddev
Eyal Lapid

Posted on January 6, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related