Dependency Injection as application design pattern
In fact, behind all the mysterious definitions and complex interpretations, the core concept of dependency injection is indeed pretty straightforward.
The dependency injection is the process of passing some objects as dependencies to the target object, so that it can use them for internal implementations.
Think of dependencies as services and the target object as a consumer. The main purpose of dependency injection is to make transparent for consumer where those services are coming from and how they work, while he knows how to use them.
Consider the code below
We have a class GoogleGeocoder
. It makes network requests to fetch current coordinates as well as coordinates by specific address.
Also we have a class Navigator
. It has one and only method getDistance
to calculate distance to the given endpoint address.
As you can see Navigator
(consumer) makes use of GoogleGeocoder
(service) when computing distance, so it can’t operate without it. In other words Navigator
depends on GoogleGeocoder
or, which is the same, GoogleGeocoder
is a dependency of Navigator
.
In this particular example, consumer itself generates the service, to consume it later on. This behavior is a direct contradiction of dependency injection pattern. But before even touching this code, let’s first take a look on issues that it can lead to, and how dependency injection can address them.
1. Inflexible
Class Navigator
is tightly coupled with GoogleGeocoder
. What if we decide to use some other service for determining coordinates? We have to modify Navigator
class to create that service instead. What if we want to use specific service depending on environment or some configuration? What if we need to create several navigators each with a different geocoder service? Indeed, It will be impossible. So this code is not configurable and hard to reuse.
2. Untestable
Feature testing is usually achieved by creating same thing in different contexts with some dummy data, and expecting desired behavior for each case. To test Navigator
, we may want to create one and call getDistance
method to ensure that it actually returns some number.
But wait, doing so we will initiate real GoogleGeocoder
network requests which is of course, not what we want.
First, We don’t want extra network requests when testing.
And second, If we are using paid API key to fetch coordinates, this way our tests may easily exhaust the whole service quota.
The solution here is to use mock geocoder that will imitate similar behavior with static or random results, rather than making real requests. But since we have hardcoded GoogleGeocoder
, we can’t configure Navigator
to use mock geocoder when testing.
3. Hard to maintain
As a rule, services are designed to be used throughout the application in many places. So imagine you have dozens of classes like Navigator
, using GoogleGeocoder
service. And all of them adopted the same manner to create the service on their own.
Now, let’s assume at some point GoogleGeocoder
has been updated to require an API key as a parameter of constructor - new GoogleGeocoder(API_KEY)
.
Or even worse, maybe we completely changed the way we create it. Perhaps now, instead of new
keyword, we use GoogleGeocoder.create()
static method to create an instance.
So how this will affect our code?
At this point we can say the following. Everything that concerns GoogleGeocoder
also concerns to its consumers, since they create the service manually. If we made any changes on how GoogleGeocoder
instance should be created, we need to modify all dependent classes one by one to make sure they create it correctly as well. So the more classes depends on our service the harder becomes to maintain it safely.
All the mentioned issues above can be considered as consequences of violating single responsibility principle. Navigator
knows too much about the service it will use. Fortunately there is a super simple yet very effective mechanism.
Introduction to Dependency Injection
Here is how looks like simplest flow of dependency injection. It’s clearly visible that now we have no tight coupling between service and the consumer. Consumer doesn’t create anything specific on its own. Instead, it expects any ready to go service as an argument and then assigns it to some instance field which will hold a reference to the injected dependency.
With this single manipulation, we are absolutely ready to challenge all the problems discussed above. Let’s see one by one.
1. Flexible
Pay attention, any time Navigator
is created we decide in place which service it should use to fetch coordinates. The consumer itself now has nothing to do with that choice. All it cares is that any injected service should at least have methods getCurrentCoordinates
and getAddressCoordinates
, also known as interface implementation. As you can see YandexGeocoder
and BingGeocoder
do implement that interface. So they can safely be used as valid geocoding services for Navigator
.
It’s obvious that this way class Navigator
can be used in much more flexible and reusable fashion.
2. Testable
Since we are free to use any valid class as geocoder service, it will be a good idea to define one for testing purposes.
Note, MockGeocoder
slightly differs from geocoders defined earlier.
- It has two optional parameters in the constructor. We can pass them as static coordinates to return from
getCurrentCoordinates
andgetAddressCoordinates
methods. - It has one additional method
generateRandomCoordinates
to avoid code duplication in two other methods. - Implementation of
getCurrentCoordinates
andgetAddressCoordinates
doesn’t perform any network requests. Results are either static or completely random.
So, will those differences violate the flow somehow.
The short answer is No.
Navigator
has nothing to do with service construction anymore. So it doesn’t care what parameters should or can be passed to the service upon creation.- Injected service should implement specific interface, that is, contain at least all the methods
Navigator
will use. The key word here is “at least”. So additional methods don’t bother us anyhow. - What about meaningless implementation,
Navigator
has no idea how coordinates are figured out. It just expects them to be an object withlat
andlng
properties. This is exactly what we get.
Note:
In real life, we must also take care of asynchronicity in tests if needed. As long as real service performs network request and returns async data, we must imitate this behavior in mock service as well, using something likePromise.resolve(coordinates)
But for this particular section, I treat all the async flow as synchronous to keep things simple and focus on our topic.
Now, we have a mock geocoder as an ideal candidate for testing Navigator
.
3. Easy to maintain
With dependency injection, consumer classes are no longer participate to service generation process. So we are free to change all the service construction rules and consumers wouldn’t even know about it. In other words we can safely maintain our dependencies without having to touch dependent classes at all.
So far so good, with just one minor change we gained so much advantages.
It can’t be that easy right?
Well, there is no magic about it. One may even compare this with ordinar function parametres. Indeed, this will be sensible assumption.
The thing is, that a curious reader will ask the following:
What is the point of all this, if we still need to maintain the code imperatively.
Playing with dependencies may still be really painful. It’s true, now we don’t have to modify consumer classes directly, but instead, we do need to modify all the points in code where we injected those dependencies.
Remember the case mentioned before. GoogleGeocoder
has updated its API. Now in order to create an instance we should use static method GoogleGeocoder.create()
Instead of new
keyword. How we actually maintain this change?
As you can see, it’s quite trivial. We simply go through every point where the service was injected and update the way we did it.
One more example. As of now we have ability to use any valid service as a geocoder for Navigator
. So let’s say we have a task to migrate from YandexGeocoder
to BingGeocoder
for some reason. That is to make all navigators using Yandex, to use Bing instead.
Again, same story!
We simply find all the points in code, where YandexGeocoder
was injected as a service, and replace it with BingGeocoder
.
These examples are for demonstration only, thus, very exaggerated. They have a lot of simple workarounds.
Factory functions alternative
We could simply write a factory function for each service. Those functions will decide which service to create as well as how to do it.
This way we will mimic Factory
pattern.
Now let’s cope with same two chalanges in one go.
1. GoogleGeocoder
has to be created with static method create
.
2. Migrate from YandexGeocoder
to BingGeocoder
.
As you can see factory functions are holding all the logic. And in case of any dependency related issue, we can support it from one single place.
Dependency graph
Actually this is a worth to mention alternative. But don’t forget that we are dealing with dummy app.
What we have right now is one consumer class Navigator
and several variations of Geocoder
service. The dependency grapfh of our app could be illustrated as follows.

It’s extremely simple, that’s why we succed so well with factory functions. But didn’t you guess that dependencies, in turn, can have their own dependencies, and so on and so forth. This chain can get more and more.
Note:
Practically, this nesting level is infinite.
Though technically, It will be restricted when nesting level exceedsMaximum call stack size
or in case of circular dependencies.
So in real life there are more complex situations that can’t be handled that easy with few factory functions.
And just to make sure you get me right, here is another dependency graph.

Note:
It’s a real dependancy graph of real project. The name and nature of the project can’t be published for sake of security.
It’s obvious that any technique mentioned before will not gonna resolve this graph. We seek for some special tool to collect all the rules, configurations and contexts in one room and control the whole flow from there. Where every object will know exatly both, what are his dependencies and who depends on him.
This is where our mechanism could be populated with one more abstraction layer called Injector.