Dependency Injection: A Gentle Introduction

Ed Mays — Copper Star Systems
The Startup
Published in
5 min readJun 9, 2020

--

Introduction: What Is Dependency Injection?

Dependency Injection is a software design principle where an object’s dependencies are provided to it, rather than the object creating its own dependencies.

I noticed that many of my students initially struggle to grasp the concepts and benefits of Dependency Injection, so I wrote this article as a general introduction.

What is a Dependency?

In a nutshell, a dependency is something a class needs in order to do its work. Imagine an application that needs to log various activities. In that scenario, you would typically have a class whose responsibility is to handle logging. Classes that need to log things therefore have a dependency on the logging component.

What exactly does ‘Injection’ mean?

‘Injection’ just refers to the concept of a class getting its dependencies from an external source (often passed as constructor arguments) rather than creating them internally.

This approach is advantageous because it allows us to specify which specific implementation will be used in which cases.

Let’s consider the logging scenario from above:

Example class with a dependency on a Logger

That doesn’t look so bad…

True, this isn’t the worst possible implementation, but it does have some subtle limitations:

  • The fact that SomeClass has a dependency on ConsoleLogger is not readily apparent unless you look at the source code. This is called a Hidden Dependency.
  • If in the future you want to change from using a ConsoleLogger to a (hypothetical) AzureQueueLogger, you have to edit SomeClass and any other classes that depend on ConsoleLogger to use the new AzureQueueLogger implementation. For a cross-cutting concern like logging, this may mean editing every class in the entire solution.
  • Because SomeClass creates its own ConsoleLogger, you cannot write automated tests that verify SomeClass interacts with ConsoleLogger as expected.
    (i.e. verifying the correct message is logged, or even if anything is logged at all)
  • For the same reason, if you write unit tests for SomeClass, you will need a fully-functioning ConsoleLogger implementation, which means that you can't test SomeClass in isolation from the rest of the system.

Now let’s refactor to inject our Logger dependency:

SomeClass refactored to inject an ILogger instance in the constructor instead of instantiating it directly

Notice that instead of creating a Logger directly, we ask for it in the form of a constructor parameter. What that means is that anyone who wants to create an instance of SomeClass must provide an instance of ILogger for it to work with.

Another subtle difference is that we now use an ILogger interface instead of a concrete Logger class. ILogger is an abstraction that allows us to substitute any type that implements ILogger without changing the SomeClass code. Abstractions and Dependency Injection often go hand-in-hand.

So what’s the Difference?

Functionally, there isn’t one— SomeClass.DoSomeWork() operates identically in both examples, however the second example resolves all of the issues from the first example:

  • It’s explicitly clear that SomeClass requires an ILogger in order to do its work - in fact, you can't create one without it.
  • We can use SomeClass with any type that implements ILogger, instead of being limited to one specific implementation. For example, we could choose to use a completely different logger without having to change the rest of the application.
  • It is now possible to write tests that verify the correct interaction between SomeClass and ILogger.
  • Because we are using the ILogger abstraction, we can provide a mock ILogger when testing, enabling us to test SomeClass in complete isolation.

In a nutshell, we can see that Dependency Injection moves the responsibility for creating dependencies outside of the class that uses them.

Is There a Downside?

As with all tools, Dependency Injection has benefits and drawbacks.

One notable side effect of the Dependency Injection pattern is that as your application grows, so does your dependency list. This can rapidly lead to unwieldy constructors, for example:

Example of a class with many dependencies injected in the constructor

So if we want to create an instance of HasManyDependencies, our code will look something like this:

Instantiating the HasManyDependencies class

That’s some fairly ugly code just to get an instance of HasManyDependencies, but it can be even worse — Imagine, for example, if LocalRepository, WebRepository, and MoonPhaseCalculator have dependencies of their own, the constructor call rapidly becomes painful to write.

The good news is that there are tools we can use to mitigate this constructor madness:

Inversion of Control (IoC) to the Rescue!

To solve the constructor complexity issue, we can use an Inversion of Control Container (IoC Container). The container takes on the responsibility of creating instances and fulfilling their dependencies in a way that greatly mitigates the impact of complex constructors.

“Inversion of Control” is a broadly-defined software engineering principle that refers to inverting the flow of control compared to traditional procedural programming. In the case of Dependency Injection, the inversion is related to dependency creation and lifecycle management.

In regular procedural programming, it is common to create instances using the new operator. When using an IoC container, we instead ask the container for an instance (called 'Resolving a type') and it gives us what we asked for. For example, instead of the mess of code above, our code would look something like this:

Example of resolving an instance of IHasManyDependencies from an IoC container

In the code above, when we call container.Resolve<IHasManyDependencies>, the container creates all of the dependencies required by HasManyDependencies (and any dependencies they may have themselves) and provides us with a ready-to-use HasManyDependencies instance.

Note: When using Inversion of Control, the IoC container must be configured by ‘registering’ the application’s types (e.g. ILogger resolves to ConsoleLogger, etc.) so that the container can resolve those types at runtime. This configuration typically occurs at application startup.

Configuring an IoC container is beyond the scope of this article, but it’s important to understand that the container doesn’t ‘magically’ know how to resolve types.

The benefit to this approach is that we now have a class (the IoC container) that has the sole responsibility for creating and injecting dependencies, freeing us from the burden of complex constructors.

Conclusion:

Although Dependency Injection may appear daunting at first, in reality it’s just a slightly different pattern that provides multiple benefits to your code:

  • Loose coupling enables more flexible, composable software implementations and facilitates code reuse.
  • Vastly improves testability by enabling test suites to inject specialized test implementations instead of relying on a production stack.
  • When combined with Abstractions, enables us to swap implementations at runtime based on runtime conditions.
  • Works cleanly with IoC containers to mitigate inherent constructor complexity.

--

--