Dependency Injections has many flavours depending on which team you join, and new ways are often being introduced. While I do not claim to have something shockingly new, I’ll share with you how I solved my Dependency Injection (DI) for an inherited code base with poorly defined dependencies.
For the curious, the following articles explore alternative ways of solving this challenge and may very well be more appropriate for your project:
First a little background: Our team was previously using Swinject to register our classes and resolve our dependencies at runtime. As some of you have most likely experienced, this sometime caused runtime errors that would only show itself under specific scenarios or when a particularly untested part of the app was accessed.
We wanted a solution that would be less susceptible to crashes, with dependency graphs that could easily be understood. More importantly we want to know at compile time if a dependency is missing without having complex code (Reader?) to deal with. KISS>
Let’s dive right in:
Our implementations all define at the top a struct that list the dependencies they will require to function. These are typically referring to easily mockable protocol definitions for easy unit testing.
This offers several advantages to other methods we have seen in other DI methods:
- Fewer assignments in the initializer
- Re-usable dependencies struct to quickly initialize multiple objects of the same type
- Compile time validation of the dependency graph — if used properly
Consider the following dependency definitions for some singletons:
This creates the following dependencies:
- ImplementationA has no dependencies
- ImplementationB depends on SomeA, which will be fulfilled by ImplementationA
- ImplementationC depends on SomeA and someB, which will be fulfilled by ImplementationA and ImplementationB
If you try to create an instance of ImplementationC, the compiler forces you to first create an instance of SomeA and SomeB. The compiler enforces the dependency graph and ensure that all your dependencies need to be fulfilled in the proper order. No runtime crashes due to Swinject not resolving a dependency you failed to register (or another developer removed).
- Your singleton initialization sequence can be tedious to define
- You may need to move your singleton setup code outside of the init() if you need to delay the setup code execution until needed ** but this may lead to runtime failures.
You can also use the same Dependencies struct for dynamically allocated view controllers or objects by defining code blocks that will return expected instances. For example:
Using dependency injected allocators allow you to mock the allocated objects during your unit tests, while still using the desired implementation at runtime.
Keeping code easy to understand simplifies developer onboarding.
This method also allows you to progressively introduce dependency injection in a project not initially designed for it. Let’s assume that you inherited the following code:
This InheritedObject has an hidden dependency to a NetworkAPI instance, we can expose it by rewriting to:
Without touching any other part of your inherited code, your InheritedObject class can now have its dependencies injected for unit testing.
At runtime by default, it will be using the NetworkAPI.shared instance as it previously did. Except now during unit test, you can pass a Dependencies() struct that points to your mock implementations.
As the project matures, you can slowly replace those .defaultDependencies with injected dependencies for compile time validation.
Using .defaultDependencies is a transition state. It is better than having hidden dependencies, but it can also lead you to an incorrect interpretation of dependency injection, and to runtime failures.
If your project is in the millions of line of code and manually defining the dependency graph is too cumbersome then consider using code generators like Needle and Weaver.
Both Needle and Weaver will parse your code for given protocol/property wrapper definitions and write a lot of the boilerplate code — while still providing compile time validation.
I hope the above was useful and/or informative. May it provide you with one more way to introduce dependency injection in your inherited projects. Easy to implement and you can run your unit tests today.