Availability Disclaimer

This article can be found on other sources:


credit photo: Bamboo Complexity, taufuuu

What is dependency injection?

Dependency injection (DI) is a very simple concept that aims to decouple components of your software and ease their integration and testing. It does so by asking for their sub-components instead of creating them.

During this article, we will also mention inversion of control (IoC), which is commonly used along with dependency injection. This pattern aims to avoid asking for implementations but rather interfaces while injecting dependencies.

This article will use a simple example in Java to present dependency injection but aims towards a technology-agnostic explanation of the concept and its advantages. Moreover, even if it is an object-oriented design pattern, you can still adapt the behaviour in many programming languages.

Let’s clarify all this using an example!

We will present a weather service that shows an intelligible representation of the weather. In the current implementation, we rely solely on a thermometer.

Let’s start without dependency injection.

Weather service without IoC

As you can see on the diagram, the WeatherService is relying on a Thermometer, which can be configured with a TemperatureUnit. Not using dependency injection will result in a code creating a new instance of Thermometer in the service, and a Thermometer configuring the TemperatureUnit to use:

public class Thermometer {
  private final TemperatureUnit unit;
  
  public Thermometer() {
    this.unit = TemperatureUnit.CELSIUS;
  }
}

public class WeatherService implements WeatherContract {

  private final Thermometer thermometer;
 
  // This constructor is not using dependency injection
  public WeatherService() {
    this.thermometer = new Thermometer();
  }
}

Now let’s imagine that we want to use a Thermometer configured to use Fahrenheit degrees instead of Celsius. For this, we add a parameter to switch between both units.

public Thermometer(boolean useCelsius) {
  if (useCelsius) {
    this.unit = TemperatureUnit.CELSIUS;
  } else {
    this.unit = TemperatureUnit.FAHRENHEIT;
  }
}

One can also argue that the user of our program won’t always have access to an actual thermometer on their device, thus you may want to be able to fall back to another implementation in this case. For instance, an API sending the current temperature in your area. Integrating multiple implementations inside the service could be done as shown below.

public WeatherService(boolean useRealDevice, 
                      boolean useCelsius,
                      String apiKey) {
  if (useRealDevice) {
    this.thermometer = new Thermometer(useCelsius);
  } else {
    this.thermometer = new ThermometerWebService(useCelsius, apiKey);
  }
}

As a result, initializing the service can be done as follows:

public static void main(String[] args) {
  // Not using dependency injection
  WeatherContract weather = new WeatherService(true, true, null);
}

Even if it is easy to use, our current version of the WeatherService is not evolutive. If we take a closer look at its constructor, we can see multiple design flaws that will haunt us in the long run:

  • The constructor is choosing its Thermometer. Adding a new type of Thermometer would require some parameter tricks to guess the implementation to use.

  • The constructor is managing the Thermometer constructor parameters. Adding the ThermometerWebService forced us to add a new apiKey parameter to it, even if unrelated to the WeatherService.

As a result, any change to any Thermometer implementation may require changes on the WeatherService constructors. This behaviour is unwanted and breaks the Separation of Concerns principle.

Will dependency injection improve my project?

Dependency injection, associated with inversion of control, is a good way to cover this use case. It allows you to choose which kind of thermometer you want in your program depending on the situation. The following diagram gives a quick overview of our new architecture:

Weather service using IoC

The inversion of control is represented in this diagram by the fact that our WeatherService implementation is linked to ThermometerContract rather than any of its implementations. That’s nothing more than this.

As for dependency injection, WeatherService will now take a ThermometerContract in its constructor, requiring the block using the service to build an instance filling this contract:

public class WeatherService implements WeatherContract {
  // We now use the Interface   
  private final ThermometerContract thermometer;

  // New constructor using dependency injection    
  public WeatherService(ThermometerContract thermometer) {
    this.thermometer = thermometer;
  }
}

As a result, the initialization of a WeatherService for both constructors will look like the following:

public static void main(String[] args) {
  // Using dependency injection
  TemperatureUnit celsius = TemperatureUnit.CELSIUS;
  ThermometerContract thermometer = new Thermometer(celsius);
  WeatherContract weather = new WeatherService(thermometer);
}

Now, our ThermometerContract can be fully configured by an external part of the software. More important so, the WeatherService doesn’t need to know any of the available implementations of ThermometerContract, thus decoupling your software packages.

This could seem like nothing important, but this simple switch of responsibility is critical leverage for multiple aspects of software design. It enables you to control the instance creation from your software entry point by chaining dependencies. You won’t have to take care of the instantiation until it is necessary. This behaviour could be compared to raised exceptions, that are ignored until taken care of in a significant context.

That’s all there is to dependency injection?

It is important to know that even if you can find libraries that help you manage your dependency injection, it is not always necessary to use them.

Those libraries tend to cover a lot of cases thus be offputting to developers not comfortable with the pattern in the first place. In reality, they simply ease the instantiation of complex dependency trees and are not required at all.

The following section is an example of injecting our service using Guice, a dependency injection framework for Java made by Google. The concept is to reference bindings of every component you can inject in your program, so that the library can generate a class of any type, automatically.

Let’s consider that we have two implementations with the following constructors:

public class WeatherService implements WeatherContract {
  private final ThermometerContract thermometer;

  @Inject
  public WeatherService(ThermometerContract thermometer) {
    this.thermometer = thermometer;
  }
}

public class Thermometer implements ThermometerContract {
  private final TemperatureUnit unit;
  
  @Inject
  public Thermometer(@Named(WeatherModule.TEMPERATURE_UNIT) 
                     TemperatureUnit unit) {
    this.unit = unit;
  }
}

The injection module should be configured to bind all needed interfaces to a given implementation. It should also be able to inject any object without a specific interface, such as the enumerate TemperatureUnit. The injection will then be bound to a specific name, “temp_unit” in this case.

public class WeatherModule extends AbstractModule {
  public static final String TEMPERATURE_UNIT = "temp_unit";

  @Override
  protected void configure() {
    // Named input configuration bindings
    bind(TemperatureUnit.class)
      .annotatedWith(Names.named(TEMPERATURE_UNIT))
      .toInstance(TemperatureUnit.CELSIUS);
      
    // Interface - Implementation bindings
    bind(ThermometerContract.class).to(Thermometer.class);
    bind(WeatherContract.class).to(WeatherService.class);
  }
}

Ultimately, the module can be used as follow, here instantiating a WeatherContract.

public static void main(String[] args) {
  // Creating the injection module configured above.
  Injector injector = Guice.createInjector(new WeatherModule());

  // We ask for the injection of a WeatherContract, 
  // which will create an instance of ThermometerContract
  // with the named TemperatureUnit under the hood.
  WeatherContract weather = injector.getInstance(WeatherContract.class);
}

Such modules usually provide a good power of customization to the injected elements, thus we can consider configuring the injection depending on the available implementations.

As a result, using a library is not required when integrating dependency injection. However, this could save a lot of time and cumbersome code in big projects.

Show me some tests!

As a side effect of decoupling your code, the dependency injection pattern is a real asset to improve unit testability of each component. This section contains an example of unit tests for our WeatherService.

As said above, making WeatherService asking for a ThermometerContract enables us to use any implementation we want. Hence, we can send a mock in the constructor, then control its behaviour from the outside.

public void testTemperatureStatus() {
  ThermometerContract thermometer = Mockito.mock(ThermometerContract.class);
  Mockito.doReturn(TemperatureUnit.CELSIUS).when(thermometer).getUnit();
  WeatherContract weather = new WeatherService(thermometer);
  
  Mockito.doReturn(-50f).when(thermometer).getTemperature();
  assertEquals(
    TemperatureStatus.COLD,
    weather.getTemperatureStatus()
  );
  
  Mockito.doReturn(10f).when(thermometer).getTemperature();
  assertEquals(
    TemperatureStatus.MODERATE,
    weather.getTemperatureStatus()
  );
}

As you can see, we can then control our thermometer without a struggle from outside our tested class.

Conclusion

Dependency injection is a way of thinking your code architecture and can be simple to implement by yourself. In bigger projects, integrating a dependency injection framework can save you a lot of time in the long run.

Dependency injection provides multiple non-negligible advantages such as:

  • Code decoupling: use the contracts and ignore implementation specificities.

  • Enhanced testability: Unit tests almost become a pleasure to write.

  • Configurability: you can more easily swap injected instances.

You can find the full code example in my design tutorials repository on GitHub