Dependency Injection via Property Wrappers
Dependency injection seems to be what all the cool kids talk about nowadays, specifically initializer/constructor based injection. The problem is that it can become challenging as a project begins to grow. Dependencies tend to be passed to intermediate objects just so that dependency is available somewhere down the road. Next thing you know, there 50 different objects passed into a view that shows a single text field (or some other rediculous use case).
Instead of injecting our dependencies through the initializer, let's try something sexier; property wrappers. As of Swift 5.1, we were introduced to property wrappers and all their glory. One of the great use cases that I came across for property wrappers is dependency injection.
Let's take a look at how we create our own property wrapper that allows us to pass our dependencies to a SwiftUI View
with very little effort (this will also work for ViewController
s or any other object).
First things first, lets make sure we are working with a common type. We can do this by creating a protocol called Injectable
.
protocol Injectable {}
Now we can create a Property wrapper that will specifically work with Injectable
objects.
@propertyWrapper
struct Inject {
let wrappedValue: T
}
Property wrappers require a property called wrappedValue
. This will hold the object we are trying to inject.
Let's also create a Resolver
object that will manage the storage of our dependencies.
class Resolver {
private var storage = [String: Injectable]()
static let shared = Resolver()
private init() {}
}
There are two important choices that we made here:
- 1. The
Resolver
is a class instead of a struct. This is beneficial because we will be modifying the storage property after theResolver
is created and don't want to make copies of theResolver
. - 2. The
Resolver
is a singleton. This prevents us from having severalResolver
s in our project which may contain different dependencies instorage
.
Since we need to update the storage
of our Resolver
, but we marked storage
as a private
property, let's go ahead and add some methods that allow us to store and retrieve dependencies.
... // private init() {}
func add(_ injectable: T) {
let key = String(reflecting: injectable)
storage[key] = injectable
}
func resolve() -> T {
let key = String(reflecting: T.self)
guard let injectable = storage[key] as? T else {
fatalError("\(key) has not been added as an injectable object.")
}
return injectable
}
... // Resolver closing }
We added add
and resolve
, both of which are generic methods that are intended to work with Injectable
objects.
When we pass in an Injectable
object to add
, it will be saved into storage
. When we resolve
an object, we are attempting to retrieve our Injectable
dependency by a stringified version of the object's type.
Note
Our implementation of theresolve
method is inferring the type that should be resolved. This means that thevar
orlet
cannot have an implied type, but must have an explicit type for the compiler to understand what type should be resolved.
Now that we can interact with our Resolver
, let's head back over to the Inject
property wrapper and add an initializer that can resolve the dependency based on the Injectable
type.
... // let wrappedValue: T
init() {
wrappedValue = Resolver.shared.resolve()
}
... // Inject closing }
Since wrappedValue
is explicitly defining the type as T
, which will be determined by the explicit type at the @Inject
call site, we can simply call Resolver.shared.resolve()
and infer which type should be resolved during initialization.
Our property wrapper is pretty much done at this point. All we need to do now is make sure that we have an object that conforms to Injectable
so we can add it to our Resolver
class MyDependency: Injectable {
func doSomething() {
print("Next level injection 💉")
}
}
For this example, let's just keep it simple and have a class that has a function that can print a statement.
Let's also create a DependencyManager
class that will add the dependencies for us.
class DependencyManager {
private let myDependency: MyDependency
init() {
self.myDependency = MyDependency()
addDependencies()
}
private func addDependencies() {
let resolver = Resolver.shared
resolver.add(myDependency)
}
}
As you can see, we are simply hanging on to a reference of each of our dependencies in the DependencyManager
, then adding our dependencies to the Resolver
in the addDependencies
method.
We're just about ready to go now, we just need to make sure that our DependencyManager
is initialized before any injected dependencies are being used. We can do this by creating an instance of our DependencyManager
during the earliest point in the app lifecycle.
• iOS 14 SwiftUI projects:
App
object
• UIKit & iOS 13 SwiftUI projects:AppDelegate
My App
object now looks like this:
@main
struct MyApp: App {
private let manager = DependencyManager()
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
We're finally ready to use our @Inject
property wrapper! 🥳
struct ContentView: View {
@Inject var dependency: MyDependency
var body: some View {
Button("Tap Me", action: dependency.doSomething)
// prints "Next level injection 💉" when tapped
}
}
And just like that, we have access to all the dependencies that the View
actually needs, without requiring it to have references to the ones it doesn't.
Thanks Property Wrappers! Now we can be cool kids too! 😎