Wrapping Dependencies in SwiftUI

Wrapping Dependencies in SwiftUI

ยท

5 min read

Dependency injection (DI) is a requirement when it comes to putting together a modular and maintainable code base. But similar to other concepts, DI can end up being a challenge when managing dependencies for larger projects often resulting in massive initializers and boilerplate factories.

With 5.1, Swift introduced a very powerful feature in the form of property wrappers. The ability to wrap values and attach additional logic to them opened up a realm of possibilities for the community to put together powerful functionality behind simple annotations. @State, @Publisher, and @ObservedObject are great examples of this.

Let's see how we can leverage that power to put together a scalable and lightweight dependency management system

Dependency Definition

If we think about it, dependencies at the end of the day are properties of an object. We typically initialize dependencies outside the scope of an object and pass them via the object's constructor. As a result, our object would need to define constructors for each dependency, potentially resulting in large constructors.

What if instead of passing them into an object, we provide these dependencies with the logic to instantiate themselves?

@propertyWrapper
struct Inject<Component> {
    let wrappedValue: Component
    init() {
        self.wrappedValue = Resolver.shared.resolve(Component.self)
    }
}

Above we define a property wrapper conveniently named Inject that upon initialization resolves its value via a Resolver object. We will dive more into dependency resolution in the next section, but to summarize the Resolver will contain the logic required to instantiate the wrapped value.

We can now use our Inject wrapper, for instance, to define dependencies for our view model:

extension TodoListView {
    class ViewModel: ObservableObject {
        @Published
        var todos: [Todo] = []
        @Inject
        private var todoRepo: TodoRepositoryProtocol

        func loadTodo() {
            todoRepo.fetchTodos()
                .assign(to: &$todos)
        }
    }
}

struct TodoListView: View {
    @StateObject
    private var viewModel = ViewModel()
    var body: some View {
        List {
            ForEach(viewModel.todos) { todo in
                Text(todo.title)
            }
        }
        .onAppear {
            viewModel.loadTodos()
        }
    }
}

Benefits to this approach:

  • Injecting the Todo Repository into our ViewModel was as simple as annotating its property with @Inject and does not require defining a constructor.
  • Since dependencies of the ViewModel class know how to instantiate themselves, our view is free of any factory patterns. Typically in DI systems that do not utilize property wrappers, views and view models would need to know how to construct themselves, resulting in dependencies being passed down from one another behind some abstracted factory boilerplate.
  • As our code evolves and we inject more dependencies to our view model, we don't need to make any adjustments to how our ViewModel is instantiated by its view.

Now that we've put together an appealing pattern for defining our dependencies, how do we go about resolving them at runtime?

Dependency Resolution

In the previous section, we defined a property wrapper that relied on a Resolver to resolve its wrapped value. This implies that our Resolver should have the knowledge to resolve any dependency throughout our app

class Resolver {
    static let shared = Resolver()

    func resolve<T>(_ type: T.Type) -> T {
        fatalError("not yet implemented")
    }
}

If we were to attempt to implement the above resolve method, we might suggest that the Resolver keep a mapping of closures for creating instances of specific types and utilize them as required.

While we could definitely put together our own implementation, I'd rather make use of a popular open source library that has done all the hard lifting for us: Swinject

Swinject comes packed with all the features you'd expect from a robust dependency injection framework but still manages to keep it simple and lightweight.

Swinject uses the container approach to dependency resolution. We register implementations for specific protocols throughout our app in a Container object:

import Swinject

func buildContainer() -> Container {

    let container = Container()

    container.register(TodoRepositoryProtocol.self) { _  in
        return TodoRepository()
    }

    return container
}

When registering dependencies, we can also specify the scope of an instance and how it's shared in the system. In the case of our TodoRepository, we might want to keep a single shared instance (singleton) of the implementation to be shared across our views:

container.register(TodoRepositoryProtocol.self) { _  in
        return TodoRepository()
    }
    .inObjectScope(.container)

In the code above, the TodoRepository instance is now registered as singleton via the injectObjectScope method and will return the same instance whenever it's resolved by other dependencies.

The registered container is then referenced and used by other objects to resolve their dependencies at runtime. In the case of our property wrapper, the Resolver can resolve the wrapped dependency via the defined container:

class Resolver {
    static let shared = Resolver()
    private let container = buildContainer()

    func resolve<T>(_ type: T.Type) -> T {
        container.resolve(T.self)!
    }
}

And that's all there is to it! When we run the app and come across the TodoListView, the @Inject property wrapper will resolve the todo repository during the view model's instantiation. Pretty neat!

Mocks and Previews

One of the main benefits of DI is its testability. By using injection, we can swap implementations with mocks for specific environments making our logic easily testable. Let's see how we can slightly adjust our resolver setup to achieve this functionality:

class Resolver {
    static let shared = Resolver()
    private var container = buildContainer()

    func resolve<T>(_ type: T.Type) -> T {
        container.resolve(T.self)!
    }

    func setDependencyContainer(_ container: Container) {
        self.container = container
    }
}

Above we've updated our Resolver with a method to override it's container. This allows us to put together another container with mocked implementations:

func buildMockContainer() -> Container {
    let container = Container()

    container.register(TodoRepositoryProtocol.self) { _  in
        return MockTodoRepository()
    }

    return container
}

Now for unit tests or SwiftUI Previews, we can swap out our dependencies with mocks:

import SwiftUI

struct TodoListView: View {
    @StateObject
    private var viewModel = ViewModel()
    var body: some View {
        List {
            ForEach(viewModel.todos) { todo in
                Text(todo.title)
            }
        }
        .onAppear {
            viewModel.loadTodos()
        }
    }
}

struct TodoListView_Previews: PreviewProvider {
    static var previews: some View {
        let mockContainer = buildMockContainer()
        Resolver.shared.setDependencyContainer(mockContainer)
        return TodoListView()
    }
}

Conclusion

DI systems can end up looking complex, resulting in a fair amount of boilerplate. In this article we put together a lightweight solution harnessing the power of Swift's property wrappers.

For further reading, I recommend you take a look at Swinject's documentation. It covers both basic and advanced topics such as object scopes and container hierarchy.

I hope you enjoyed this article and found it useful. If you have any questions, comments or feedback i'd love to hear them. Contact me or follow me on Twitter

ย