When to Use ES2015 Modules Instead of Angular Dependency Injection (and When Not to)

When to Use ES2015 Modules Instead of Angular Dependency Injection Title Image

Derived from photo by fdecomite / flickr.com, CC-BY

This post discusses some of the nuances between ECMAScript 2015 module dependencies and Angular's dependency injection system. These are design considerations and while you may decide not to use the approaches discussed, the goal is to provide you with a context in which to make more thoughtful decisions about how to use dependencies throughout your Angular application.

A Tale of Two Dependency Systems

When building an Angular application, there are two main dependency systems at play. The first is EcmaScript 2015 (ES2015) modules. This is the system used when creating an import statement or exporting an object. This system uses string identifiers to obtain dependencies via URL or npm package name.

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

The other dependency system is Angular's dependency injection (DI) system which is built on top of the ES2015 module system. Unlike the static nature of ES2015 dependencies, these dependencies are configurable when the application bootstraps. They are typically defined as a provider in an NgModule.

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule
  ],
  providers: [
    ShoppingCartService
  ],
  bootstrap: [ 
    AppComponent 
  ]
})
export class AppModule {

}

While not entirely a one-to-one relationship, consider the idea that ES2015 module dependencies are hard dependencies meaning they cannot easily change upon running the application. On the other hand, Angular provides a more loosely-coupled dependency structure that is more easily configured at runtime.

Note: There are in fact more than two dependency mechanisms if you consider npm module resolution where version numbers can influence the resolution of ES2015 modules or a bundler like webpack which can conditionally swap ES2015 dependencies based on configuration. This would be tooling-based configuration whereas this post focuses on code-based configuration.

Angular Dependency Injection (DI)

At a high level, Angular reads the NgModule metadata during the bootstrap process and creates an injector capable of providing these dependencies through the class constructor of components and services throughout the application. Please read more about Angular's dependency injection in the documentation to understand the full details. This post refers to Angular dependency injection at a high level.

To look at an example, consider an e-commerce application with a digital shopping cart. Imagine a mini-view of the cart while the user is browsing product pages and a separate, detailed view when it's time to check out. The logic to calculate the quantity of items in the cart is the same for each view. Therefore, both pieces of the UI may depend on a ShoppingCartService to calculate the total quantity of items in the cart. This service is provided to the components through dependency injection.

shopping-cart.service.ts

import { Injectable } from '@angular/core';
import { CartDetail } from './shopping-cart.model';

@Injectable()
export class ShoppingCartService {
  getTotalQuantity(cartDetails: CartDetail[]): number {
    if (!cartDetails) {
      return null;
    }

    const total = cartDetails.map(carDetail => carDetail.quantity)
      .reduce((previous, current) => previous + current, 0);

    return total;
  }
}

shopping-cart.component.ts

import { Component } from '@angular/core';
import { CartDetail } from './shopping-cart.model';
import { ShoppingCartService } from './shopping-cart.service';

@Component({
  selector: 'app-shopping-cart',
  templateUrl: './shopping-cart.component.html'
})
export class ShoppingCartComponent {

  shoppingCart: CartDetail[] = [
    { productName: 'Hammer', quantity: 1 },
    { productName: 'Purple Shorts', quantity: 26 },
    { productName: 'Web Shooter', quantity: 2 }
  ];

  totalCartQuantity = this.shoppingCartService.getTotalQuantity(this.shoppingCart);

  constructor(private shoppingCartService: ShoppingCartService) { }
}

Consider the number of steps involved in registering and using this dependency in a component through Angular's DI:

  1. Create the ShoppingCartService class
  2. Import and decorate the dependency with the @Injectible() decorator
  3. Import the dependency into the appropriate NgModule
  4. Register the service as a provider
  5. Import the dependency in the component's ES2015 module
  6. Add it as an argument in the component's constructor
  7. Use the dependency

That's quite a bit of code to implement the default DI recommendation. This isn't a criticism, just an observation. Because this implementation uses the ES2015 module standard and includes the added TypeScript benefits, these steps are reasonable in pursuit of loosely-coupled dependencies.

Note: The Angular CLI can help you avoid having to write some of this code by hand.

While Angular's dependency injection implementation comes with many benefits, it's worth considering an alternative.

Using ECMAScript 2015 (ES2015) Module Dependencies

Instead of creating a class and registering it with an NgModule, consider writing a more generic piece of code that can map and calculate a total from an array of objects. Create a function called mapArraySum in its own ES2015 module. Look at the steps for using this function in a component:

  1. Create the mapArraySum function
  2. Import the dependency in the component's ES2015 module
  3. Use the dependency

mapArraySum.ts

export function mapArraySum<T>(array: T[], mappingFunc: (item: T) => number): number {
  if (!array) {
    return null;
  }

  const total = array.map(mappingFunc)
    .reduce((previous, current) => previous + current, 0);

  return total;
}

shopping-cart.component.ts

import { Component } from '@angular/core';
import { CartDetail } from './shopping-cart.model';
import { mapArraySum } from './mapArraySum';

@Component({
  selector: 'app-shopping-cart',
  templateUrl: './shopping-cart.component.html'
})
export class ShoppingCartComponent {

  shoppingCart: CartDetail[] = [
    { productName: 'Hammer', quantity: 1 },
    { productName: 'Purple Shorts', quantity: 26 },
    { productName: 'Web Shooter', quantity: 2 }
  ];

  totalCartQuantity = mapArraySum(this.shoppingCart, item => item.quantity);
}

Wow, this is a much simpler approach. Furthermore, the dependency is completely decoupled from anything Angular-specific like @Injectable. Which implementation should you use? There are several considerations that should factor into this decision.

When to Consider Using ECMAScript 2015 Modules

The reason the ShoppingCartService is probably not a good candidate for straight ES2015 module dependency management is twofold. First, the UI of a shopping cart is generic enough where you may want to re-use the shopping cart UI components in multiple applications. These applications may employ different shopping cart logic. Second, the checkout process is prone to business changes and totaling a cart may change very much depending on promotions, shipping rates, or other business rules. You may find that calculation can no longer be done on the client and must involve a call to the server.

Potentially, it makes sense to employ both implementations where the ShoppingCartService exists as a loosely-coupled dependency yet its implementation leverages the generic mapArraySum function.

shopping-cart.service.ts

import { Injectable } from '@angular/core';
import { CartDetail } from './shopping-cart.model';
import { mapArraySum } from './mapArraySum';

@Injectable()
export class ShoppingCartService {
  getTotalQuantity(cartDetails: CartDetail[]): number {
    const total = mapArraySum(this.shoppingCart, item => item.quantity);
    return total;
  }
}

Before detailing what criteria indicates a potential candidate for ES2015 dependency management, remember that when in doubt the safer choice is to use Angular's dependency injection. In the end, it provides much greater flexibility if you ever need it.

These criteria potentially identify code suitable to use ES2015 dependency management:

  1. Pure functions with stateless logic (e.g. calculations, validations, mapping data structures)
  2. No external dependencies (e.g. HTTP requests)
  3. Generic functionality (e.g. language-level functionality, contract-based data mapping)
  4. Throw-away code for prototyping purposes

Given these criteria, and other than rough prototypes, what type of code specifically does this cover? This type of code can go by many names, but falls into the category of utility functions. This is code that helps you do things that you repeatedly do in any JavaScript application. It's the code you get sick of writing over and over. A simple example is a function that checks if an object is null or undefined.

export function isNullOrUndefined(value: any) {
    const isNullOrUndefined = value === null || value === undefined;
    return isNullOrUndefined;
}

This is a one-liner, but consider building on it:

export function isAnyNullOrUndefined(values: any[]) {
    // this would be more lines without support for .some()    
    const isAnyNullOrUndefined = values.some(value => isNullOrUndefined(value));
    return isAnyNullOrUndefined;
}

// another common example
export function isEmptyString(value: string) {
    const isEmptyString = !isNullOrUndefined(value) && value.length === 0;
    return isEmptyString;
}

What about a function that adds all the numbers in an array and rounds to a specific number of decimal places? This logic is prone to errors or inconsistencies in implementation. Do you round before or after you add? How do you guard against precision errors? These are all things a developer shouldn't have to keep revisiting while building an application. How do you perform a deep clone or a deep compare of two objects? These are all examples of common logic that developers use again and again.

Could you still put this logic in an Angular service and inject it into a component? Absolutely. The point is that you have another option that might prove more simple, elegant, and/or re-usable. Ultimately, choose what's best for your team and your project.

Use ES2015 Modules to Abstract Hard Dependencies

You may have been thinking during the last section, "Aren't there utility libraries to use for these types of utility functions?". There are and developers use them every day. The question is, should your application take a hard dependency on these libraries?

Based off the information thus far, this functionality can probably safely exist as an ES2015-based dependency. However, you can partially ease the coupling by abstracting the specific framework or library reference to within a library under your control. In this way, if you decide for examples that lodash's deep compare function is no longer required because of an emerging ECMAScript standard, you can easily replace it in one place without affecting the rest of the application.

Consider an isEqual module:

isEqual.ts

import * as _ from 'lodash.isequal';

export function isEqual(target: any, comparison: any) {
    const isEqual = _.isEqual(target, comparison);
    return isEqual;
}

app.component.ts

import { isEqual } from './app-utility/isEqual.ts';

@Component({
    selector: 'my-app',
    template: './app.component.html'
})
export class AppComponent {
    foo: any;

    onSomeEvent(newFoo: any) {
        if (isEqual(this.foo, newFoo)) {
            return;            
        }

        console.log('foo is new');
        this.foo = newFoo;
    }
}

So instead of repeatedly coupling your components to third party libraries, depend on your own utility library where you can replace or remove third-party utility libraries as necessary.

Conclusion

Again, this post is intended to help you think more deeply about the dependencies in your Angular application. There are many different approaches to handling these designs. What approaches do you use to manage dependencies in your applications?