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.
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:

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.

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.




