Skip to main content

Dependency injection in JavaScript

Escrito por:
Jerry Ejonavi
wordpress-sync/feature-buffer-overflow

17 de novembro de 2022

0 minutos de leitura

Inversion of control (IoC) techniques give developers a way to break out of traditional programming flow, and it offers them more flexibility and greater control over their code. Dependency injection, one form of IoC, is a pattern that aims to separate the concerns of constructing objects and using them.

In this article, you'll learn what dependency injection is, when you should use it, and what popular JavaScript frameworks it's implemented in.

What is dependency injection?

Dependency injection is a software design pattern where an object or function makes use of other objects or functions (dependencies) without worrying about their underlying implementation details. The task of providing dependencies to objects where they're needed is left to an injector, sometimes called an assembler, provider, or container.

For instance, consider how video game consoles only need a compatible disc or cartridge to function. Different discs or cartridges usually contain information about different games. The gamer doesn't need to know anything about the console's internals and usually doesn't do anything other than insert or replace game discs in order to play a game.

Designing software or writing code this way makes it easy to remove or replace components (like in the example above), write unit tests, and reduce boilerplate code in a codebase.

While dependency injection is a pattern commonly implemented and used in other languages, notably Java, C#, and Python, it's less often seen in JavaScript. In the following sections, you'll consider the pros and cons of this pattern, its implementation in popular JavaScript frameworks, and how to set up a JavaScript project with dependency injection in mind.

Why do you need dependency injection?

To fully understand why dependency injection is helpful, it's important to understand the problems IoC techniques address. For instance, consider the video game console analogy from earlier; try to represent a console's functionality with code. In this example, you'll call your console the NoSleepStation, and you'll work with the following assumptions:

  • A NoSleepStation console can only play games designed for the NoSleepStation.

  • The only valid input source for the console is a compact disc.

With this information, one could implement the NoSleepStation console in the following way:

1// The GameReader class
2class GameReader {
3  constructor(input) {
4    this.input = input;
5  }
6  readDisc() {
7    console.log("Now playing: ", this.input);
8  }
9}
10
11// The NoSleepStation Console class
12class NSSConsole {
13  gameReader = new GameReader("TurboCars Racer");
14
15  play() {
16    this.gameReader.readDisc();
17  }
18}
19
20// use the classes above to play 
21const nssConsole = new NSSConsole();
22nssConsole.play();

Here, the core console logic is in the GameReader class, and it has a dependent, the NSSConsole. The console's play method launches a game using a GameReader instance. However, a few problems are evident here, including flexibility and testing.

Flexibility

The previously mentioned code is inflexible. If a user wanted to play a different game, they would have to modify the NSSConsole class, which is similar to taking apart the console in real life. This is because a core dependency, the GameReader class, is hard coded into the NSSConsole implementation.

Dependency injection addresses this problem by decoupling classes from their dependencies, only providing these dependencies when they are needed. In the previous code sample, all that the NSSConsole class really needs is the readDisc() method from a GameReader instance.

With dependency injection, the previous code can be rewritten like this:

1class GameReader {
2  constructor(input) {
3    this.input = input;
4  }
5
6  readDisc() {
7    console.log("Now playing: ", this.input);
8  }
9
10  changeDisc(input) {
11    this.input = input;
12    this.readDisc();
13  }
14}
15
16class NSSConsole {
17  constructor(gameReader) {
18    this.gameReader = gameReader;
19  }
20
21  play() {
22    this.gameReader.readDisc();
23  }
24
25  playAnotherTitle(input) {
26    this.gameReader.changeDisc(input);
27  }
28}
29
30const gameReader = new GameReader("TurboCars Racer");
31const nssConsole = new NSSConsole(gameReader);
32
33nssConsole.play();
34nssConsole.playAnotherTitle("TurboCars Racer 2");

The most important change in this code is that the NSSConsole and GameReader classes have been decoupled. While an NSSConsole still needs a GameReader instance to function, it doesn't have to explicitly create one. The task of creating the GameReader instance and passing it to the NSSConsole is left to the dependency injection provider.

In a large codebase, this can significantly help to reduce code boilerplate since the work of creating and wiring up dependencies is handled by a dependency injection provider. This means you don't need to worry about creating instances of the classes that a certain class needs, especially if those dependencies have their own dependencies.

Testing

One of the most important advantages of dependency injection is found in unit testing. By delegating the work of providing a class's dependencies to an external provider, mocks or stubs can be used in place of objects not being tested:

1// nssconsole.test.js
2const gameReaderStub = {
3  readDisc: () => {
4    console.log("Read disc");
5  },
6  changeDisc: (input) => {
7    console.log("Changed disc to: " + input);
8  },
9};
10
11const nssConsole = new NSSConsole(gameReaderStub);

As long as the dependency's interface (the exposed methods and properties) does not change, unit testing a simple object with the same interface can be used in place of an actual instance.

Limitations of dependency injection

It's important to note that using dependency injection is not without its disadvantages, which are usually related to how dependency injection attempts to solve the problem of reducing code boilerplate.

In order to get the most from dependency injection, especially in a large codebase, a dependency injection provider has to be set up. Setting up this provider can be a lot of work and is sometimes unnecessary overhead in a simple project.

This problem can be solved by using a dependency injection library or framework. However, using a dependency injection framework requires a total buy-in in most cases, as there aren't many API similarities between different dependency injection frameworks during configuration.

Dependency injection in Popular JavaScript Frameworks

Dependency injection is a key feature in many popular JavaScript frameworks, notably Angular(), NestJS, and Vue.js.

Angular

Angular is a client-side web development framework complete with the tools needed to build, test, and maintain web applications. Dependency injection is built into the framework, which means there's no need for configuration, and it's the recommended way to develop with Angular.

Following is a code example of how dependency injection works in Angular (taken from the Angular docs):

1// logger.service.ts
2import { Injectable } from '@angular/core';
3
4@Injectable({providedIn: 'root'})
5export class Logger {
6  writeCount(count: number) {
7    console.warn(count);
8  }
9}
10
11// hello-world-di.component.ts
12import { Component } from '@angular/core';
13import { Logger } from '../logger.service';
14
15@Component({
16  selector: 'hello-world-di',
17  templateUrl: './hello-world-di.component.html'
18})
19export class HelloWorldDependencyInjectionComponent  {
20  count = 0;
21
22  constructor(private logger: Logger) { }
23
24  onLogMe() {
25    this.logger.writeCount(this.count);
26    this.count++;
27  }
28}

In Angular, using the @Injectable decorator on a class indicates that that class can be injected. An injectable class can be made available to dependents in three ways:

  • At the component level, using the providers field of the @Component decorator

  • At the NgModule level, using the providers field of the @NgModule decorator

  • At the application root level, by adding providedIn: 'root' to a @Injectable decorator (as seen previously)

Injecting a dependency in Angular is as simple as declaring the dependency in a class constructor. Referring to the previous code example, the following line shows how that can be done:

1constructor(private logger: Logger) // Angular injects an instance of the LoggerService class

NestJS

NestJS is a JavaScript framework developed with an architecture that is effective for building highly efficient, reliable, and scalable backend applications. Since NestJS takes heavy inspiration from Angular in its design, dependency injection in NestJS works very similarly:

1// logger.service.ts
2import { Injectable } from '@nestjs/common';
3
4@Injectable()
5export class Logger {
6  writeCount(count: number) {
7    console.warn(count);
8  }
9}
10
11// hello-world-di.component.ts
12import { Controller } from '@nestjs/common';
13import { Logger } from '../logger.service';
14
15@Controller('')
16export class HelloWorldDependencyInjectionController  {
17  count = 0;
18
19  constructor(private logger: Logger) { }
20
21  onLogMe() {
22    this.logger.writeCount(this.count);
23    this.count++;
24  }
25}

Note that in NestJS and Angular, dependencies are typically treated as singletons. This means that once a dependency has been found, its value is cached and reused across the application lifecycle. To change this behavior in NestJS, you need to configure the `scope` property of the @Injectable decorator options. In Angular, you would configure the `providedIn` property of the @Injectable decorator options.

Vue.js

Vue.js is a declarative and component-based JavaScript framework for building user interfaces. Dependency injection in Vue.js can be configured using the `provide` and `inject` options when creating a component.

To specify what data should be made available to a component's descendants, use the provide option:

1export default {
2provide: {
3message: 'hello!',
4}
5}

These dependencies can then be injected in components where they're needed by using the inject option:

1export default {
2  inject: ['message'],
3  created() {
4    console.log(this.message) // injected value
5  }
6}

To provide an app-level dependency, similar to providedIn: 'root' in Angular, use the provide method from the Vue.js app instance:

1import { createApp } from 'vue'
2
3const app = createApp({})
4
5app.provide(/* key */ 'message', /* value */ 'hello!')

JavaScript dependency injection frameworks

So far, you've considered dependency injection in the context of a JavaScript framework. But what if someone wants to start a project without using any of the aforementioned frameworks and they don't want to implement a dependency injection container/resolver from scratch?

There are a few libraries that provide dependency injection functionality for this scenario, namely injection-js, InversifyJS, and TSyringe. You'll focus on InversifyJS in the following section, but it may be worth taking a look at the other packages to see if they're a better fit for your needs.

InversifyJS

InversifyJS is a dependency injection container for JavaScript. It's designed to add as little runtime overhead as possible while facilitating and encouraging good object-oriented programming (OOP) and IoC practices. The project's repository can be found on GitHub.

To use InversifyJS in a project, it needs to be added to the project's dependencies. Here, you'll set up InversifyJS with the gaming example used throughout. So set up a new project with npm init. Open a terminal, and run the following commands in order:

1mkdir inversify-di
2cd inversify-di
3npm init

Next, add inversify to the project's dependencies using npm or Yarn:

1# Using npm
2npm install inversify reflect-metadata
3
4# or if you use yarn: 
5# yarn add inversify reflect-metadata

InversifyJS relies on the Metadata Reflection API, so you need to install and use the reflect-metadata package as a polyfill.

Next, add the code for the NSSConsole and GameReader classes by first creating two empty files:

1touch game-reader.mjs nssconsole.mjs

And then continue to append the following code to each file, respectively:

1// game-reader.mjs
2export default class GameReader {
3  constructor(input = "TurboCars Racer") {
4    this.input = input;
5  }
6
7  readDisc() {
8    console.log("Now playing: ", this.input);
9  }
10
11  changeDisc(input) {
12    this.input = input;
13    this.readDisc();
14  }
15}
1// nssconsole.mjs
2export default class NSSConsole {
3  constructor(gameReader) {
4    this.gameReader = gameReader;
5  }
6
7  play() {
8    this.gameReader.readDisc();
9  }
10
11  playAnotherTitle(input) {
12    this.gameReader.changeDisc(input);
13  }
14}

Finally, configure the project to use InversifyJS:

1touch config.mjs index.mjs
1// config.mjs
2import { decorate, injectable, inject, Container } from "inversify";
3import GameReader from "./game-reader.mjs";
4import NSSConsole from "./nssconsole.mjs";
5
6// Declare our dependencies' type identifiers
7export const TYPES = {
8  GameReader: "GameReader",
9  NSSConsole: "NSSConsole",
10};
11
12// Declare injectables
13decorate(injectable(), GameReader);
14decorate(injectable(), NSSConsole);
15
16// Declare the GameReader class as the first dependency of NSSConsole
17decorate(inject(TYPES.GameReader), NSSConsole, 0);
18
19// Declare bindings
20const container = new Container();
21container.bind(TYPES.NSSConsole).to(NSSConsole);
22container.bind(TYPES.GameReader).to(GameReader);
23
24export { container };
1// index.mjs
2// Import reflect-metadata as a polyfill
3import "reflect-metadata";
4import { container, TYPES } from "./config.mjs";
5
6// Resolve dependencies
7// Notice how we do not need to explicitly declare a GameReader instance anymore
8const myConsole = container.get(TYPES.NSSConsole);
9
10myConsole.play();
11myConsole.playAnotherTitle("Some other game");

You can test the new setup by running node index.mjs from the project's root.

This example, though minimal, can serve as a good starting point for most projects looking to work with InversifyJS.

Conclusion

In this article, you learned about dependency injection in JavaScript, its pros and cons, examples in popular JavaScript frameworks, and how to use it in a vanilla JavaScript project.