Layered Dependency Injection With Swift Property Wrappers

Resolve dependencies at compile-time

Ihor Vovk
Better Programming

--

Photo by Jeremy Bezanger on Unsplash

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:

--

--