The provided content discusses common bad practices in Dependency Injection (DI) and offers solutions to avoid them, emphasizing the importance of clear and manageable dependency management for maintainable and testable code.
Abstract
The article "Dependency Injection Bad Practices" delves into several antipatterns that developers often encounter when implementing Dependency Injection. It begins by emphasizing the necessity of understanding DI basics before tackling more complex issues. The author identifies the direct instantiation of dependencies within components as a major antipattern, advocating instead for a single composition root where all dependencies are injected. This approach enhances code clarity and testability. The article also criticizes the use of service locators or context providers, suggesting that components should only be injected with the dependencies they explicitly need. Another point of discussion is the injection of overly broad dependencies, which obscures a component's actual requirements. The solution proposed is to pass only the necessary minimum to maintain clarity and flexibility. The article further advises against accessing environment variables outside the wiring section and cautions against unnecessary use of DI, recommending its application only when it adds clear value. The author also warns against using the wrong type of injection, such as setters or mutable fields, advocating for immutable dependencies passed solely through constructors. Additional advice includes avoiding passing dependencies to methods, which can lead to confusion, and being wary of components with too many dependencies, which may indicate a god object. Lastly, the article stresses the importance of creating shared dependencies only once to prevent unnecessary re-creation.
Opinions
The author strongly opposes the direct instantiation of dependencies within components, viewing it as the worst antipattern due to the difficulty in tracking dependencies and the negative impact on testability.
The use of service locators or context providers is seen as a practice that should be avoided, as it leads to components having access to more dependencies than necessary, complicating testing and understanding of the code.
Injecting overly broad dependencies is criticized for making the actual needs of components unclear, with the recommendation to pass only the bare minimum required.
Accessing environment variables outside the wiring section is considered an antipattern that complicates testing and hides dependencies within the codebase.
The author believes that not everything needs to be injected and that DI should be used judiciously, only when it provides a clear benefit, such as varying behaviors per environment or adhering to the dependency inversion principle.
The article suggests that the complexity of a solution should be aligned with the complexity of the problem it solves, implying that overuse of DI can be a code smell.
The author advocates for passing dependencies exclusively through constructors in object-oriented programming, making component dependencies immutable and predictable.
The practice of passing dependencies to methods is discouraged, as it blurs the line between component-scoped and call-scoped dependencies.
Components with an excessive number of dependencies are seen as potential god objects, which should be broken apart to reduce complexity and improve testability.
The article emphasizes the importance of creating shared dependencies, such as monitors, loggers, and databases, only once to avoid redundancy and misconfiguration.
Instantiating dependencies directly in components where they’re needed is the opposite of DI. Hidden dependencies make code work like magic and you never know what depends on what. This is by far the worst antipattern because it’s hard to track where a network or database call is made. To be fair, it’s not a DI antipattern because you’re not using it.
A common solution is to make the components access a wide scope (e.g. a global function or singleton) but that makes refactoring painful and testing very hard (the monkey patching hell).
📝 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.
The web of dependencies is a mystery when you’re accessing everything everywhere. We should aim for pure functions (deterministic and predictable) but hidden dependencies make that hard to achieve.
Solution: Dependencies should be created and injected in a single place/file, usually the app’s entry point, i.e., “main” (i.e. the composition root). The app’s wiring becomes evident here which works toward self-documenting code. When you open a component, its dependencies become clear. Beware this applies to code dependencies (e.g. gateways, databases) but also to non-deterministic functions (e.g. system clock, id. generators) and literals (e.g. environment variables for configurations like base URLs).
Passing all the dependencies
A service locator or a context provider has almost the same issues as accessing a global scope: dependencies are hard to grasp and testing is hard. Also, each component has access to more than it should.
Beware if you’re passing a wider scope than it’s 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. Also, it makes the component more cohesive.
Solution: Rather than passing a big dependency, just pass the bare minimum; it makes a component’s real dependencies evident and easier to vary. For example: why pass an HTTP client when you could pass the base URL only? In this case, testing could be done using a fake server approach (which is more realistic).
// ⛔️ injecting a pre-configured HTTP client:
DiscountsClient(httpClient)
/️️/ ⛔️ injecting the whole configuration provides no clue what are the reals needs of UserProfiler:
UserProfiler(config, featureFlags)
// ✅ injecting only the base URL and letting the HTTP client be an implementation detail of DiscountsClient:
DiscountsClient(discountsBaseUrl)
// ✅ injecting only the required values makes the UserProfiler needs evident:
UserProfiler(
baseUrl="https://api.service.com",
debug=System.getenv("DEBUG") == "true",
)
Accessing env vars outside the wiring
Using env vars in the code itself (other than the wiring place) is an antipattern because it makes testing harder and creates dependencies that become hidden in the middle of the code.
Solution: Env vars (and any other configuration) should be injected from the outside in. They can only be referenced in the wiring place.
Injecting without advantages
The complexity of a solution should always be aligned with the complexity of the problem it solves. DI is a solution to a problem and it adds a tiny bit of complexity. Not everything needs to be injected; if the side-effect is merely technical and non-impacting for testing purposes (e.g. a generic logger) then it’s fine to leave it hardcoded (it can even be a singleton! Why not?).
Solution: Apply DI when there’s some added value. Here are some valid uses cases:
if you need to vary something (e.g. a configuration value or a behavior) per environment, per request, 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 carefully because changing implementation solely due to tests is a code smell) (in Python, using @Patch is a clear sign that you need DI).
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 (in OOP) — if you want to avoid a direct dependency (e.g. a use case should not directly depend on a database; it should only know about its interface).
There’s some tension between this guideline and having hidden dependencies that are hard to recognize and test. Therefore, try to find a good balance and be consistent. I believe that the default is to use DI but there are exceptions. I tend to isolate and inject API gateways, data persistency components, configuration values, and the system clock, among others.
Wrong injection type
Some people do DI through setters or mutating class fields. This creates temporal coupling because you must not forget to set the dependencies after the component instantiation. Also, mutable dependencies create uncertainty about what a certain component can do. It also hinders self-documenting code.
Solution: In the object-oriented paradigm, pass dependencies solely to the constructor. This way, you can’t create components without their minimum requirements. Also, make them immutable. This makes the component behavior predictable.
An alternative to passing dependencies to constructors is to pass them to the actual methods that need them. At least in OOP, this is a bit confusing because it’s hard to differentiate what is component-scoped and what is call-scoped.
As you know, a service should get all of its dependencies and configuration values injected as constructor arguments. But information about the task itself, including any relevant contextual information, should be provided as method arguments. Object Design Style Guide
Too many dependencies
A component needing more than two dependencies is suspicious; more than that should raise an alarm. Probably, you’re dealing with an over-busy component like a god object. This antipattern is common on business code hotspots. It makes tests unnecessarily complex as you have to mock lots of things that won’t be relevant to the subject being tested.
Solution: Components with low coupling should be broken apart. If we’re talking about business logic, you can split them by use case. This also makes testing much easier as it becomes more focused.
Multiple creations of the same dependency
Dependencies like monitors, loggers, and databases are usually shared so they should be created and configured once and passed multiple times.
Solution: Create the dependency once and pass it to multiple components. The remaining dependencies should be inlined to make it more evident that they’re used in a single place. Recall that all this should be done in the wiring section of your app.