Dependency injection in JavaScript
17 de novembro de 2022
0 minutos de leituraInversion 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
decoratorAt the NgModule level, using the
providers
field of the@NgModule
decoratorAt 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.