Dependency Injection
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
- Dependency Injection in JavaScript by Gediminas Bačkevičius on DevBridge
- JavaScript Dependency Injection Thread on Stack Overflow
- Dependency Injection in JavaScript by Krasimir Tsonev
- JavaScript Dependency Injection in Node.js – friend or foe? by Adam Polak
- A Quick Intro to Dependency Injection: What It Is, and When to Use It by Bhavya Karia on FreeCodeCamp
- SOLID: The First 5 Principles of Object Oriented Design