avatarLuís Soares

Summary

Dependency Injection (DI) is a fundamental design pattern that facilitates the management and replacement of components' dependencies, enhancing code maintainability, testability, and self-documentation.

Abstract

Dependency Injection (DI) is a design pattern that involves passing dependencies to components rather than having them create or retrieve these dependencies themselves. This approach ensures that components are provided with what they need to function, which can range from high-level to low-level dependencies such as databases, APIs, and configuration values. DI promotes the easy replaceability of dependencies, allowing for greater flexibility in testing and development, such as the ability to swap implementations at runtime or delay technology decisions. It also supports principles like the separation of concerns, single-responsibility, and self-documenting code. The maturity model of DI illustrates the progression from tightly coupled components to a decoupled architecture where dependencies are managed externally. While DI can be implemented without libraries, using DI libraries and containers can further streamline the process, though understanding the DI tree of an application is crucial to avoid antipatterns. Constructor injection is recommended for its clarity and reliability in specifying dependencies. Proper wiring at the composition root ensures that all dependencies are explicitly injected, providing a clear overview of the application's architecture.

Opinions

  • DI is considered a simple yet powerful concept that does not necessarily require complex libraries or frameworks, as it can be effectively implemented by passing dependencies as constructor arguments.
  • The author advocates for "vanilla DI" over relying on DI libraries, emphasizing that DI is primarily a design concern rather than a technological one.
  • Monkey patching during testing is seen as a sign of hidden dependencies, which DI aims to eliminate, making code more transparent and easier to maintain.
  • The author values the self-documenting nature of DI, as it makes the dependencies of each component clear and supports the creation of more maintainable and understandable codebases.
  • The use of interfaces, lambdas, or duck typing in conjunction with DI is encouraged to adhere to the dependency inversion principle and maintain a high level of abstraction.
  • The article suggests that the reluctance to use DI may stem from a lack of understanding of its benefits or the misconception that it is overly complex, when in fact, it simplifies the code and its dependencies.

Dependency Injection — Back to the Basics

DI is a design pattern that ensures that components have their dependencies passed on rather than having to create or obtain them.

Image by Spencer via Unsplash Copyright-free

DI is just a fancy name for passing an object its dependencies; i.e. what it needs. A dependency is another component, a function, or simply a value.

“Dependency Injection” is a 25-dollar term for a 5-cent concept. […] Dependency injection means giving an object its instance variables. Really. That’s it. Dependency Injection Demystified

Everything has dependencies, ranging from high-level to low-level:

  • Apps depend upon databases, third-party APIs, the system clock, and configuration values;
  • Use cases need repositories to accomplish their goal (e.g. a use case that transfers money needs a repository);
  • API clients require the server base URL and possibly some configuration values;
  • Entities (domain-centric design) need value objects and primitives to be initialized;
  • Value objects need their raw/primitive values to be initialized (e.g. an email requires a valid string).

Dependencies should be explicitly supplied to the objects’ constructors. Ideally, they should be immutable, non-nullable, and validated. This ensures that no invalid object exists floating around in your app.

Why do I need DI?

DI enables the easy replaceability of dependencies — these should behave as plugins. Here’s why DI matters:

  • You insolate non-pure calls like getting the system clock or using an id. random generator — in testing, you can easily inject test doubles thus granting determinism;
  • You can swap the implementation in runtime, usually for request-based feature flagging;
  • You may start a project using an in-memory database (or a file system) delaying the tech decision for later (the last responsible moment, in a lean approach);
  • It can support you if you’re swapping a provider such as a database.

You can justly argue that swapping a database is so rare that DI is not worth the trouble, but be aware that depended-on components are not restricted to databases — there’s tech volatility happening in many other realms. Also, take into consideration all the other benefits of DI:

  • Configure once; use multiple times: discrete and decoupled components are easily shareable (e.g. a configured AWS S3 client);
  • Separate what from how: preventing high-level stable code (e.g. business) directly depending on low-level volatile code (e.g. technology) (known as the dependency inversion principle);
  • Adopting the SLA principle: improves readability, promotes the single-responsibility principle, and prevents huge blocks of code;
  • Each component’s dependencies become evident which works towards self-documentation.

📝 Hidden dependencies make code work like magic and you never know what depends on what. If you feel the need to monkey patch things while testing (e.g. Python’s @Patch, Jest’s module mocking, Rails monkey patching), then you have a hidden dependency. While refactoring this type of code, there’s pain (i.e. find-and-replace-based refactoring) and fear.

Finally, take into account that the alternatives to DI are not better in any form since DI is really simple but powerful. By just passing dependencies as arguments we achieve all of the above.

Maturity model

An enlightening way to understand DI is by dissecting how we got there — through a maturity model where each level concedes an additional decoupling between components:

DI is about passing pre-configured dependencies to whoever needs them. The details about the dependencies configuration are to be done outside, beforehand, on the app’s boot-up.

Just a few more notes regarding these levels:

  • In level 1, there might be lots of code repetition.
  • In levels 1 to 3, you probably need monkey patching to be able to test the component since dependencies are not easily replaceable. Also, it’s hard to recognize each component’s dependencies.
  • From level 3 onwards, we achieve the principle of separating configuration from use (components don’t instantiate/configure their dependencies).
  • On level 6 we follow the dependency inversion principle: the component does not commit to a specific implementation of the dependency. It can be used in conjunction with levels 4 and 5. The typical pattern is the use of interfaces but you can also pass lambdas or resort to duck typing.

DI libraries and containers

I tend to use libraries when it pays off (when it does, I try to limit their influence and segregate their usage). Every time I see DI done with libraries (e.g. dry-auto_inject, Koin) I wonder why such an important but simple concept relies on an external party.

DI is a design concern; not a technology. You don’t need a DI library to achieve something as simple as passing parameters to constructors (or methods/functions). Here’s how clear vanilla DI is:

val coffeeProvider = CoffeeProvider(System.getenv("PROVIDER_URL"))
val coffeeNotifier = CoffeeNotifier()
val coffeeMaker = CoffeeMaker(coffeeProvider, coffeeNotifier)

If you’re using all-in-one frameworks such as Spring Boot, understandably, you resort to their embedded DI but at least try to understand your app’s DI tree and be careful with some antipatterns.

Containers are the managers of the dependencies (and their lifecycles) of your app. Since we’ll do manual wiring (without any third-party library), we don’t care much about containers. Well, we could call it a hand-written container. Our main DI pattern will be constructor injection.

Using constructor injection

There are a few types of DI, but I always recommend passing dependencies as constructor arguments of the component which needs them. Here’s an example:

val registerClient = RegisterClient(
   emailSender = mockk(),
   clientRepository = object : ClientRepo() {
      fun findById(id: Long) = Client(id)
   },
   logger = mockk(),
)

In the example above, the RegisterClient component dependencies are evident. That’s true in the test but also in the implementation. Furthermore, this clarity prevents missing dependencies or dependencies with invalid types. It also guarantees that you validate them in a single place.

Alternatively, you can pass functions rather than objects. This is especially relevant in the functional programming (FP) paradigm (alternatively, you could drop the class and just resort to function parameter injection):

class UpgradeUser(
    private val userRepository: UserRepository,
    private val userNotifier: Notifier,
    private val getRetries: () -> String,
) {
    operator fun invoke() {
        val retries: String = getRetries()
        // …
    }
}

OK, we’ve seen how to inject components into others, but how to set up our hand-written container of dependencies? We’ll do it in a centralized place and we’ll call it wiring.

Wiring

Let’s put it all together. Wiring means the assembling of your app’s components — all the DIs happening in one place, forming a graph of dependencies. It should be placed at the composition root, which is the app’s entry point (e.g. main()), where you prepare/inject dependencies, instantiate the app, and boot it up. What does the wiring part look like in the code? Here’s an example:

fun main() {
   val app = RecordingsApp(
     accessControl = AccessControl(),   
     recordingRepo = RecordingRepo(
       database = Database.connect(
         url = System.getenv("DB_URL")
       ),
       logger = MyLogger(
         level = Level.WARN,
       ),
     ),
     recordingUploader = RecordingUploader(
        apiBaseUrl = System.getenv("API_URL"),
     ),
     clock = Clock.systemUTC(),
     monitor = NewRelicMonitor(),
   )
   app.start()
}

This setup is a good example of self-documenting code because it centrally describes your app’s web of components: the dependencies, their clients, and the degrees of nesting. Because of that, it can and should be used as a tool to identify some DI design smells.

Services should be created in one go, providing all their dependencies and configuration as constructor arguments. All service dependencies should be explicit, and they should be injected as objects. Object Design Style Guide

Proper wiring allows you to assemble your app and plug in different components. It provides a cohesive control panel where you can manage your app’s dependencies.

Wiring reminds me of an integrating circuit with all the components connected through colored wires (Photo by Manoel Lemos on Flickr)

Conclusion

DI promotes code consistency hence supporting building new functionality but also helping maintainability. It directly supports two of the SOLID principles: the single-responsibility principle and the dependency inversion principle. It also serves the purpose of code self-documentation.

What led me to write the article was the number of dependencies that have to be patched in the tests, making refactoring painful. Given the simplicity and advantages of DI, I can’t understand why it’s not used more often.

Learn more

My clean architecture sample project applies the article’s recommendations.

Dependency Injection
Recommended from ReadMedium