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 ViewControllers 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<T: Injectable> {
    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:

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<T: Injectable>(_ injectable: T) {
    let key = String(reflecting: injectable)
    storage[key] = injectable
}

func resolve<T: Injectable>() -> 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 the resolve method is inferring the type that should be resolved. This means that the var or let 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! 😎

Share on Twitter