Dependency Injection is a software design pattern in which an object receives other instances that it depends on. It’s a commonly used technique that allows reusing code, insert mocked data, and simplify testing. An example could be initializing a view with the network provider as a dependency.
There are many different solutions for dependency injection in Swift, which all have their own pros and cons. It turned out to be a very opinionated topic when I asked on Twitter which solution engineers prefer to use. Before diving into my solution today, it’s good to know many good solutions exist. It’s not a given my solution works for everyone, so feel free to explore the world of dependency injection and find the one that fits you best.
Dependency Injection without 3rd party library
My approach, in general, is to find solutions in Swift that take away the requirement of a 3rd party library. An external library makes it easy and faster to get started right away. However, it might also be too tempting just to get started with an external library while combining a few of Swift’s powerful features would have been enough too.
By staying close to Swift’s standard features, you take away the learning curve of an external library, and you’re no longer dependent on new releases. There’s always a risk of breaking changes in the library or finding out that the library is no longer maintained. On the other hand, writing your own solution requires knowledge you might not have at hand. Hopefully, this article will give you the input you need to write a little extension in your project to handle dependency injection without a 3rd party framework.
Why would you need dependency injection?
What problem is being solved with dependency injection? It’s an important question to ask yourself before writing or picking your solution. In my team, we recently revisited our approach to dependency injection as it didn’t match our needs anymore now that our project became bigger. We defined the following points to be solved based on the issues we experienced with our current solution:
- Mocking data for tests should be easy
- Readability should be maintained by staying close to Swift’s standard APIs
- Compile-time safety is prefered to prevent hidden crashes. If the app builds, we know all dependencies are configured correctly
- Big initialisers as a result of injecting dependencies should be avoided
- The AppDelegate should not be the place to define all shared instances
- No 3rd party dependency to prevent potential learning curves
- Force unwrapping shouldn’t be needed
- Defining standard dependency should be possible without exposing private/internal types within packages
- The solution should be defined in a package that can be shared across libraries for reusability
These points describe our project’s state before we started revisiting our dependency management. Over time, our project became bigger and bigger with less consistency in terms of injecting dependencies. We had several classes with big initializers; decreasing readability and mocking data wasn’t always easy.
The AppDelegate as an answer to not creating singletons
We did not want to create singletons everywhere in our project as it was seen as a bad pattern (a different discussion I won’t start here today). Yet, we still had to share a lot of the same references, so we decided to instead define them in the AppDelegate
to make them accessible throughout the app. This resulted in a few problems:
- Using
AppDelegate.shared
we still kind of used a singleton for each shared instance - You always need to access the
AppDelegate
from the mainthread to prevent thread sanitizer warnings - The
AppDelegate
is not accessible from app extensions
Over time, we moved away several instances from the app delegate, but we still had a big list of defined instances that made dependency injection hard to do. Another reason to revisit our approach.
Writing a solution using Swift features like static subscripts, extensions, and Property Wrappers
When writing solutions yourself, it’s good to get inspired by existing code. These could be 3rd party libraries, which are often good at using Swift features, but might need a few changes to fit your needs. You can also look at Swift’s standard APIs like the @Environment
property wrapper in SwiftUI. We liked this approach in which environment configurations are injected into SwiftUI views.
A Property Wrapper allows injecting dependencies and reduces code clutter on the implementation side. There’s no need for big initializers, and there’s still the possibility to override dependencies for tests. A property wrapper also makes it clear which properties are injected, which can increase readability.
In the following example, we have a NetworkProvider
conforming to the NetworkProviding
protocol. We also have a mocked version of this network provider called MockedNetworkProvider
.
protocol NetworkProviding {
func requestData()
}
struct NetworkProvider: NetworkProviding {
func requestData() {
print("Data requested using the `NetworkProvider`")
}
}
struct MockedNetworkProvider: NetworkProviding {
func requestData() {
print("Data requested using the `MockedNetworkProvider`")
}
}
After configuring our new dependency injection solution, the final code looks as follows:
struct DataController {
@Injected(\.networkProvider) var networkProvider: NetworkProviding
func performDataRequest() {
networkProvider.requestData()
}
}
You can see that we defined a new property wrapper that takes a key path reference. In the data request performing method, we can use this network provider directly. Running this code in a playground shows the following output:
var dataController = DataController()
print(dataController.networkProvider) // prints: NetworkProvider()
InjectedValues[\.networkProvider] = MockedNetworkProvider()
print(dataController.networkProvider) // prints: MockedNetworkProvider()
dataController.networkProvider = NetworkProvider()
print(dataController.networkProvider) // prints 'NetworkProvider' as we overwritten the property wrapper wrapped value
dataController.performDataRequest() // prints: Data requested using the 'NetworkProvider'
It’s important to point out that adjusting the dependency using the InjectedValues
static subscript also affects already injected properties. This makes sure you don’t end up with side effects and weird outcomes due to inconsistent dependency references. In other words: all your injected dependencies will reference the same injected instance. Using the wrapped value setter of the property wrapper, we also allow updating dependencies through the data controller itself.
Our solutions follow the environment properties solution from SwiftUI closely. Therefore, we start by defining a InjectionKey
protocol:
public protocol InjectionKey {
/// The associated type representing the type of the dependency injection key's value.
associatedtype Value
/// The default value for the dependency injection key.
static var currentValue: Self.Value { get set }
}
We create a new key for our network provider to conform to this protocol:
private struct NetworkProviderKey: InjectionKey {
static var currentValue: NetworkProviding = NetworkProvider()
}
As you can see, we defined the key as private. We can do this as we’re going to expose the actual key path to use in the property wrapper using an extension on a new type called InjectedValues
:
extension InjectedValues {
var networkProvider: NetworkProviding {
get { Self[NetworkProviderKey.self] }
set { Self[NetworkProviderKey.self] = newValue }
}
}
This way, we solve one of our points, ensuring we keep control of exposure while performing dependency injection. We don’t want all implementors to be aware of the NetworkProvider
, but instead, just let them work with the NetworkProviding protocol. Doing so allows us to adjust the implementation of the NetworkProvider
without affecting code at the implementation level as long as the protocol remains unchanged.
Let’s have a look at the InjectedValues
instance:
/// Provides access to injected dependencies.
struct InjectedValues {
/// This is only used as an accessor to the computed properties within extensions of `InjectedValues`.
private static var current = InjectedValues()
/// A static subscript for updating the `currentValue` of `InjectionKey` instances.
static subscript<K>(key: K.Type) -> K.Value where K : InjectionKey {
get { key.currentValue }
set { key.currentValue = newValue }
}
/// A static subscript accessor for updating and references dependencies directly.
static subscript<T>(_ keyPath: WritableKeyPath<InjectedValues, T>) -> T {
get { current[keyPath: keyPath] }
set { current[keyPath: keyPath] = newValue }
}
}
This structure mainly acts as a dependency resolver. We defined a static property current
as an accessor for our static subscript as key paths can only refer to non-static members. This allows us to reference dependencies using the key path accessor as shown in the example above: @Injected(\.networkProvider)
.
The property wrapper works closely together with the InjectedValues
struct:
@propertyWrapper
struct Injected<T> {
private let keyPath: WritableKeyPath<InjectedValues, T>
var wrappedValue: T {
get { InjectedValues[keyPath] }
set { InjectedValues[keyPath] = newValue }
}
init(_ keyPath: WritableKeyPath<InjectedValues, T>) {
self.keyPath = keyPath
}
}
We make use of a computed property to ensure referencing the same dependency everywhere. Updating the dependency directly through the static subscript of InjectedValues
or by making use of the property wrapper wrapped value both results in the same source being updated.
Conclusion
Altogether, this solution allows us to improve our dependency management without adding an external library. There’s not much code to maintain ourselves, and for new engineers joining our project, it should be easy to get up to speed as we’re staying close to existing solutions like the environment values in SwiftUI.
If you like to learn more tips on Swift, check out the Swift category page. Feel free to contact me or tweet to me on Twitter if you have any additional tips or feedback.
Thanks!