Layered Dependency Injection With Swift Property Wrappers
Resolve dependencies at compile-time
Purpose
- A simple solution for dependency injection
- Compile-time resolving of dependencies
- Provide different scopes of injectable values for different layers: view model, view, router, interactor, etc.
- Reduce the number of dependencies passed via
init
methods - Avoid spreading abstractions from an external library across the project.
Solution
Even though the solution is property injection, it also provides benefits usually attributed to constructor injection. These become possible owing to property wrappers and keypaths. They allow resolving all the dependencies during compile-time. If any is missing, a project won’t build. It means that every instance with such dependencies will have them set up upon creation.
Since we inject dependencies via properties, a list of parameters passed to init
methods is reduced. We won’t need to pass all dependencies at every place of instance creation.
A good project has a layered structure. For instance, if we follow clean architecture, we may come up with the following layers: view, view model, interactor (use case), app state, etc. And most likely, you don’t want all these layers to have access to the same dependencies. The solution uses keypaths to structure dependencies providing only those for every layer allowed by design.
Let’s see how it works. Code samples below are from a test project. You can find a link at the end of this article.
Injected Property Wrapper
Here is how we implement the property wrapper:
We use a keypath to identify a dependency injected. The prefix of such keypath determines a layer - a scope of dependencies. For example, injecting into a view model, we are going to use \.viewModel.
:
Similarly, an interactor’s injections are accessible with the \.interactor.
prefix in a keypath:
A great thing here is that you don’t need to specify a type of injectable variable. The compiler infers it through the keypath.
Root Dependency Container
Layers are customizable. You specify them in the root DependencyInjection
class:
Layers
In our example, we provide injections for only two layers: interactors and view models. Protocols allow us to hide implementation details of the containers and, for example, to use mocked injections for tests. Here is the definition of the protocols:
Both layers have access to appState
. But only interactors may perform calls to network
.
If we set up injections for the view layer, we probably wouldn’t add appState
there, allowing views to access appState
only via their view models.
The actual implementation of InteractorInjectionProtocol
is straightforward:
For ViewModelInjectionProtocol
, we provide two implementations, including a mock version for tests:
We pass an instance of AppState
via init
because it is shared. The lazy
keyword used here is not crucial for gitHubInteractor
, but it would be reasonable to use it with injections that we may not always need during a single app run.
For complete understanding, let’s see the implementation of both GitHubInteractor
and MockGitHubInteractor
:
Both interactors implement the same protocol. Inside they also use the dependency injection mechanism. They access injectable values from the interactor layer. Since all dependencies are injected directly into properties, we don’t need to pass them via an init
method.
Assembly
To provide an assembly of all injections available in our project, we add it as a computed property to an extension of the DependencyInjection
class:
We use different assemblies for the application itself and while running the project for previews. Because there is no need for network calls in previews.
Tests
Here is how we can set up our assembly in test cases:
Conclusion
The developed solution has the following essential advantages:
- Even though it is a property injection, injected values become available upon an object construction. Resolving is performed during compilation. Traits of keypaths ensure it.
- We don’t overload
init
methods with extra dependencies. Dependencies are injected directly into properties. - It allows you to control access to dependencies from different layers.
- It is a simple solution and does not require a code of an external library.
Resources
You can find the source code used in this article in my GitHub repository: