Good practices when using dependency injection

Dependency Injection (DI) is the most common way to decoupling and separation of concerns, promoting testability and readability. Let’s analyze some related design patterns that I’ve spotted in some projects.

Luís Soares
Mar 15 · 8 min read

📝 I used Kotlin in the code samples, but all the patterns apply regardless of the language or runtime.

You don’t need a DI framework

I use frameworks and libraries when it pays off. In what concerns DI, I recommend doing it manually. Check how clear and effective vanilla DI is:

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

DI is a design concern and the way to achieve it is to learn its concepts. If you’re using a library (e.g. Guice, Dagger, Koin), at least try to understand its underlying magic. This also applies to all-in-one frameworks such as Spring Boot.

You don't need DI all the time

The solution complexity should always be aligned with its corresponding problem complexity. DI is a solution to a problem and it adds complexity. Therefore, don’t use it if you don’t have that problem.

Use DI if and when it brings value:

  • if you need to vary something (e.g. a configuration value or a behavior) per environment (e.g. staging, live) or to prevent environment variable dependencies in the code (they should be isolated in the wiring section).
  • to be able to unit test a component (use it sparingly because changing implementation solely due to tests is a code smell).
  • if you need to ensure the same component is used across others (e.g. an AWS S3 client, a database).
  • to respect the dependency inversion principle — if you want to avoid a direct dependency (e.g. a use case should not directly depend on a database; it should only know its interface).

Use constructor injection

There are a few types of DI:

I always recommend using constructor injection because it makes it hard to build a component without some of its dependencies. It makes each component's dependencies evident in tests and implementation. Here’s an example of a component in a test class where you can clearly see what it depends on:

📝 Use named arguments if the language supports them. That significantly increases readability.

If the dependency is optional (uncommon), you can still use constructor injection and set the dependency to some default value:

Alternatively, you can pass functions rather than objects. This is especially relevant in the functional programming paradigm:

Protect dependencies

Making dependencies private ensures no one, other than their client, will interact with them. Immutable (i.e. final) dependencies guarantee no one will change them later. Non-nullable dependencies documents and enforces them as mandatory. Here’s an example:

📝 If the language doesn’t support enforced non-nullable types, make sure you null-check the dependencies in the constructor. This ensures invalid objects (i.e. with missing dependencies) can’t be created.

Centralize wiring

Wiring is what assembles all the parts of your app. In other words, it is the sum of all the DIs of the app. I also heard some people calling it plumbing.

📝 The wiring code is usually placed at your main function. To make your main thin (a good practice), you can also extract into a function that you invoke from there.

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

In a domain-driven architecture, the wiring should be done outside all layers. It’s part of the infrastructure of your app and must therefore be properly extracted. How does it look like in code? Here’s an example:

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 design smells. Here are a few examples:

  • ⚠️ Too many dependencies — a component needing more than two dependencies is suspicious; more than four should raise an alarm. Check if you have an over-busy component. This also makes testing so much easier and focused.
  • ⚠️ Multiple creations of the same dependency — horizontal dependencies like monitors and loggers are usually shared so they should be created/configured once and passed multiple times.
  • ⚠️ Too much nesting — having a high degree of nesting might reveal that you are injecting components that are too tiny. You need to ask yourself whether they are implementation details; maybe they don’t need to be exposed to the outside, particularly if they’re used only once.
  • ⚠️ Not respecting the layers and dependency directions — the wiring may reveal some violations of the layers and the dependencies’ directions of your app (a way to enforce it is to write ArchUnit tests). For example, your domain should never reference a concrete adapter but rather an interface.
  • ⚠️ Passing “big objects” — beware if you’re passing a wider scope than it’s actually needed (e.g. the whole configuration rather than just the required values) since that obscures the real needs of the components, in tests and implementation. Rather than passing a big dependency, pass just the bare minimum; it makes a component’s real dependencies evident and easier to vary.

Centralize loading of configuration values

Never load environment variables, config files, command-line arguments, or similar, inside your app’s components. That makes testing very hard and it hides those dependencies. Configuration values are also dependencies. That said, load them in the same place you set up the wiring — near your main function. It will make them evident and easily configurable in automated tests.

Consider lazy loading

Sometimes you want to run your app locally but don’t feel you need to set up all the env vars. If you’re not using all the parts, why should the app fail to start? Lazy loading is the answer. It’s also useful if some dependency is expensive to build or has some undesired side-effect. The downside of lazy loading is that, if something's misconfigured, the app only fails in run time rather than in boot time. This may be dangerous, so use it wisely. Here’s how:

Encapsulate the creation of dependencies

I like to see dependencies as pluggable parts ready to be used. Using the customizable PC metaphor, you can imagine these parts (e.g. camera, graphics card) in a box, at your disposal and ready to be assembled. In software, the dependencies (e.g. use cases, repositories, gateways) can also be in a single place, like a pile of building blocks. I usually put them in objects that I can pass around. In the example below, you can see two possible configurations I can boot up my app with:

This is another way to say that you should separate the building of dependencies from their wiring. Encapsulating dependencies enables having different configurations depending on your needs. For example, you could have a configuration for local development. You could also launch a frontend app that depends on server APIs with a configuration that relies solely on fakes.

Consider wiring for feature toggling

Rather than having ifs spread across your business logic, what if you injected different versions of dependencies according to feature flags (e.g. from env vars) as if they were plugins? Feature flags can be part of the wiring infrastructure. Think about a customizable PC where you can easily swap each of its parts without breaking its general intent.

An obvious benefit is that the branching decision happens early, which increases safety (the old code is not even wired/running). This power is only unleashed if you have your business logic split per use case, which I recommend anyway since it makes testing so much easier and respects the SRP.

This technique provides pleasant management of feature flags, especially when toggling them but also when it’s time to delete the old code. It also provides a way to inject dry-run (side-effects-free) versions of components. You could even deploy multiple versions of the same app.

Image by Jp Valery via Unsplash Copyright-free

Create interfaces only when needed

In statically typed languages, creating interfaces for every component is a common behavior that you should question.

DI is the proper way to guarantee the dependency inversion principle, which states that:

[…] stable software architectures are those that avoid depending on volatile concretions, and that favor the use of stable abstract interfaces. Clean Architecture, Chapter 11

The inner you go in the circle, the more stable. The arrows above the circles show the valid dependencies’ directions (volatile parts can depend on stable parts, not the other way around)

In other words, the inner parts of your app should not directly depend on the outer parts, which are more volatile; for example, the domain should never depend on a concrete database repository. The other way around is fine; for example, a web handler depending on the domain. The bottom line is, if you’re not violating the dependency rule, you don’t need to create an interface. Another valid reason to create an interface is to have multiple versions of the same component (e.g., in feature toggling).

Applying patterns in DI promotes predictability hence supporting building new functionality but also helping maintainability. It also serves as code self-documentation.

With these recommendations we can conclude that there should be a clear line separating dependency creation/injection from your app — wiring is just assembling details. Proper wiring allows you to assemble your app and plug and play different components. It provides a control panel where you can manage your app’s dependencies.

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

Image by Spencer via Unsplash Copyright-free


Everything connected with Tech & Code

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store