Dependency Injection

  • JavaScript
  • Fundamentals
  • Code
  • SOLID

Today I'm going to write about a software engineering pattern called Dependency Injection. Doing my usual Google searches on the topic yielded lots of computer science theory that felt way above my head, but I wanted to try to break the concept down to something understandable.

What is Dependency Injection?

Dependency Injection is a software design pattern that allows the coder to remove hard-coded dependencies and make it possible to change those dependencies. Dependencies can be injected to the object via the constructor or via defined method or a setter property.

If the above definition made your eyes glaze over or felt like reading a foreign language, then you're right where I was when I was doing my research. I prefer a nice, clear example. Luckily I found one in Nesa Mouzehkesh's answer on this Stack Overflow thread, which I'm basing the following on.

Dependency Injection Example: Printer

The example revolves around a printer, which has an LCD screen that displays a message when it performs a print job. We start with a base case, in which we don't use any Dependency Injection:

Case 1: No Dependency Injection

class Printer {  constructor() {    this.lcd = "";  }  print(text) {    this.lcd = "Printing...";    console.log(`This printer prints ${text}!`);  }}
var printer = new Printer();printer.print("Hello");

This example is pretty easy to follow. We declare a Printer class, and within its constructor define a property named lcd, which by default is an empty string. The printer, on its own with nothing else going on, will display no text on the LCD screen.

We then define a print method, which accepts a parameter named text. When this method is called, the lcd property is set to the string 'Printing...', and we console.log(This printer prints ${text}!), where ${text} is the text passed as an argument.

Outside of the Printer class, we are creating a new Printer assigned to the variable printer, and then calling printer's print method, passing the string 'Hello' as an argument.

When we run this code, the print method console.log()s 'This printer prints Hello!', and if we check printer.lcd, we get 'Printing...', which we can think of as the message that the printer will display on the LCD screen.

So what is wrong with this approach? It's simple and clear, but it's not flexible. The lcd property is hardcoded, so making that LCD screen say anything other than This printer prints ${text}! is difficult. What if we wanted to display an error message, like 'Out of paper' or communicate that we are printing something in color or in black and white? This kind of modularity is achieved by a dependency that we'll call a Driver. Let's introduce a Driver into our next case:

Case 2: Abstracting Functionality Using a Driver

class Printer {  constructor() {    this.lcd = "";    this.driver = new Driver();  }  print(text) {    this.lcd = "Printing...";    this.driver.driverPrint(text);  }}
class Driver {  driverPrint(text) {    console.log(`The Driver prints ${text}!`);  }}
var printer = new Printer();printer.print("Hello");

This code still feels pretty clean and easy to follow. We have created a new Driver class that only contains one method, driverPrint, which accepts a single parameter named text and console.log()s 'The Driver prints ${text}!', where ${text} is what will get passed as an argument when this method is called.

Back inside of the Printer class, the constructor() method now also constructs a driver property, which is instantiated from the new Driver class. The print method now calls the driverPrint method from the Driver class and passes along the text we want to print.

Finally once again we are creating a new Printer assigned to the variable printer, and then calling printer's print method (brought over from the Driver class), passing the string 'Hello' as an argument.

When we run this code it console.log()s 'The Driver prints Hello!', and if we check printer.lcd, we get 'Printing...'.

So we've abstracted out the print functionality out of the Printer class, but it's still not flexible. We are still using the new keyword inside of the Printer constructor, meaning the Driver is still hard-coded into the printer. In a real world, practical example, our printer would essentially have a built-in driver that cannot be changed.

A better pattern would be to pass the driver as an argument to the constructor. This would allow us to (drumroll please) inject the driver as a dependency, so we could pass in any kind of driver we want, whether it would be for a black and white print job or a color print job or something else altogether.

Case 3: Injecting the Driver as a Dependency

class Printer {  constructor(driver) {    this.lcd = "";    this.driver = driver;  }  print(text) {    this.lcd = "Printing...";    this.driver.driverPrint(text);  }}
class BlackAndWhiteDriver {  driverPrint(text) {    console.log(`The Driver prints ${text} in Black and White!`);  }}
class ColorDriver {  driverPrint(text) {    console.log(`The Driver prints ${text} in Color!`);  }}
var bwDriver = new BlackAndWhiteDriver();var printer = new Printer(bwDriver);printer.print("Hello");

Now things are getting more interesting. We have two new classes: BlackAndWhiteDriver and ColorDriver. They both have a driverPrint method which takes a parameter, text, and prints that text as well as whether it will be printed in Black and White or in Color.

Inside of the Printer class, the constructor now accepts a parameter named driver and sets that value as the driver property. This driver property is what gets used within the print method.

Below the classes, we instantiate a new BlackAndWhiteDriver and save it into a variable named bwDriver. Then we instantiate a new Printer and save it into a variable named printer, passing bwDriver as the driver argument. We then call printer.print('Hello').

When we run this code, it console.log()s 'The Driver prints Hello in Black and White!'

This is great. The Driver is constructed in isolation from the Printer's construction, allowing us to achieve a lot more modularity than the previous examples, but it's not ideal. The user (or the method that runs the code) has to know which driver to instantiate. The problem is that we are creating a new Printer instance each time we need to print something and change the driver. This happens because we pass our driver to the Printer class at construction time.

So in our previous example, if the user wanted to switch to a color print job, they would have to do something like the following:

var cDriver = new ColorDriver();var printer = new Printer(cDriver);printer.print("Hello");

This would indeed give us 'The Driver prints Hello in Color!', but reassigning the printer variable is bad practice.

Case 4: Use a Setter Function to Switch Drivers

class Printer {  constructor() {    this.lcd = "";  }  setDriver(driver) {    this.driver = driver;  }  print(text) {    this.lcd = "Printing...";    this.driver.driverPrint(text);  }}
class BlackAndWhiteDriver {  driverPrint(text) {    console.log(`The Driver prints ${text} in Black and White!`);  }}
class ColorDriver {  driverPrint(text) {    console.log(`The Driver prints ${text} in Color!`);  }}
var bwDriver = new BlackAndWhiteDriver();var cDriver = new ColorDriver();var printer = new Printer();
printer.setDriver(bwDriver);printer.print("Hello");
printer.setDriver(cDriver);printer.print("Hello");

The code above introduces a new method to Printer called setDriver, which accepts the driver to use when printing.

This gives us the best of all worlds. We simply have to instantiate our drivers and the printer once, and then we use the setDriver function whenever we want to select a new driver, passing it the desired driver as an argument.

When we run the above code, we get "The Driver prints Hello in Black and White!" and "The Driver prints Hello in Color!", and only have to create one Printer.

In this example, the various drivers are our dependencies, and they are injected into our program as needed in a way that is as modular and flexible as possible.

This is a very basic example of the concept, and there are ways this sort of thing can get very complicated, especially as your code ecosystem grows or your application gets big and relies on a lot of models and controllers. This is about where my understanding of the concept begins to break down, but there are lots of libraries around that can assist in managing dependency control.

Definition and Terminology

With the basic example of the way, it's time to revisit the definition of Dependency Injection as well as some common terminology.

Dependency Injection is a software design pattern that allows the coder to remove hard-coded dependencies and make it possible to change those dependencies. Dependencies can be injected to the object via the constructor or via defined method or a setter property.

Typically the object that receives the dependencies is called the Client, and the passed-in ("injected") object is called a Service.

Dependency Injection is an implementation of Inversion of Control (IoC). IoC states that a client should not configure its dependencies statically, but instead should be configured by some other class from outside.

This is the fifth principle of SOLID - the five basic principles of object-oriented programming and design by Uncle Bob. You can read more about SOLID here.

Advantages and Disadvantages of Dependency Injection

Advantages

  • Decoupling - this pattern allows an engineer to decouple the main functionality of a program from dependencies that allow flexibility within that program. As some see it, things are better when as little is hard-coded as possible
  • Flexibility - you're building for an interface and abstracting smaller operations. Proper implementation allows for great flexibility
  • Testing - when the code that governs how a service (for example, one of our printer drivers) is abstracted from the client (the printer itself), the service itself can much more easily be tested

Disadvantages

  • Configuring Defaults - our final Printer implementation is a bit onerous in terms of choosing to simply do a black and white print job. Architecture that relies on dependency injection can get in the way of easily implementing default behavior
  • Spaghetti Code - too much abstraction can be a bad thing. When everything is split out into separate components then stack tracing an error in a deep-dependency tree can be tough
  • Complexity - overall it can just be a complex architecture pattern to follow. For smaller apps it can be immense overkill, and with larger apps you would likely rely on some framework to help manage the Dependency Injection, which would contribute to the maintenance overhead

Sources / Further Reading