In this page, we'll provide a brief introduction to dependency injection and related topics, then outline how we manage it for this website and our general approach.
Introduction
Dependency injection is the act if passing, or injecting, a dependency into a module which requires it. The opposite of this is hard coding the dependency.
- Hard Coded Dependency: The dependency is built into the class/function/module, most likely using a new invokation.
- Dependency Injection: The dependency is listed as a parameter in the constructor / function signature. The caller is required to pass the dependency in. Note that this means any implementation which satisfies the type / interface can be passed in.
The following sections will outline the approaches and their pros & cons.
Hard Coding Dependencies
At first glance, this may seem like the easier approach; instead of worrying about passing dependencies in from place to place, just new one up. The following is an example of this approach:
This code will work and is probably good enough for some simple, one time only script. However, for anything more, this will lead to some problems. The first problem which comes to mind is testing. As the code is now, the developer will need to create actual files on the hard disk for the unit test to read. This will slow down the test case execution and now brings in a dependency on the underlying operating system just to run the unit tests. There are multiple ways to solve this, but for this example, we will refactor the code above to use dependency injection.
Dependency Injection
The code below takes the previous example and rewrites it to use dependency injection. That code is almost exactly the same; the difference lies in that it uses dependency injection. Notice there is no new invokation now. For unit tests, we can now create a mock file reader and pass that in, allowing us to write tests without the need to read files or interact with the file system.
Even Further
Now that we've introduced dependency injection, we are thinking more clearly about the domain. Let's say a new requirement came in to not only read from local files, but now remote files as well. With this dependency injection approach in place, we can create a new object which has the same type as our original file reader. This object will read remote files, not local files. Let's compare and contrast how we can implement this with and without dependency injection:
In the hard coded example, we need to figure out a way to provide the additional functionality. This implementation is a bit clumsy because it needs to create an entirely new class with the same type definition. If not that, it would have needed to update the type definition with getLocalStats and getRemoteStats, which can lead to reusability problems in the future.
On the other hand, the dependency injection example easily accomodates the new requirement. The alternate remote file reader is injected, and no code change is required on the file stats class. This also means that any additional file reading requirement can be accomodated in the future as well. Note that there are other ways of achieving this, such as removing the file reading part from the fileStats entirely; this implementation was choosen to exemplify dependency injection.
Other Benefits of Dependency Injection
Better unit testing is not the only benefit of using dependency injection:
- The class/object is configurable: anything which matches the type definition of the required dependencies can be passed in / injected, allowing for more dynamic/polymorphic behavior.
- Coupling between an objects and its dependencies is reduced: the definitions for a class and its dependencies can be given in separate locations, and neither needs to know any details about the other's implementation.
- Modules created following the dependency injection methodology are more reusable: since they don't need to direclty reference dependencies, they are easier to reuse and integrate in different parts of the code base.
- Code duplication is generally reduced when this pattern is followed: since it is easier to inject code, required functionality is more likely to be injected instead of copied and pasted.
- Writing high quality unit tests is more feasible: all dependencies can be stubbed and fine-tuned for each test scenario, and unit tests don't need to carry any baggage from any hard-coded dependencies.
Dependency Wiring
Now that we are using dependency injection, we need to know: where will we define and inject the dependencies? In most cases, the dependencies are constructed, or newed up, in the main class / method. The main method can choose which dependencies to create based on some command line parameters or configuration, or it can create dependencies unconditionally. We will not go into detail on dependency wiring here, but point out that it will be much simpler to declare and inject dependencies at the appropriate levels when dependency injection is used throughout.
Further Reading
The book Dependency Injection Principles, Practices, and Patterns is an execellent book which introduces the topics of dependency injection, inversion of control, service locator, and other patterns and practices related to managing dependencies. It also includes common anti-patterns, how to avoid them, and how to move away from them to adopt better practices.
- Refactoring existing code into loosely coupled code
- DI techniques that work with statically typed OO languages
- Integration with common .NET frameworks
- Useful conceptual overview which can be adapted to any language and tech stack