A gentle introduction to dependency injection in Swift

Vitor Ferraz Varela
5 min readMay 28, 2024

--

Dependency Injection is a design pattern in software development where an object receives its dependencies from external sources. This approach facilitates code reuse, allows for the insertion of mock data, and simplifies testing.

Swift offers numerous solutions for implementing dependency injection, each with its own advantages and drawbacks. However, one thing is for sure: if you are dealing with a huge class to store all your data or multiple singletons, it’s probably time to rethink how you handle your dependencies.

Today, we will discuss a possible solution to help you get started. As a disclaimer, the solution here may not fit all scenarios, and it’s important to remember that the Swift community has created many excellent solutions to this problem. Feel free to choose the one that best suits your needs.

Starting with the basics

For our solution, we will use something called a Dependency Container, which stores data using a key/value pair dictionary. Most dependency injection solutions use a very similar approach to provide access to the dependencies.

We’ll begin with a simple basic structure and then refactor it to use modern Swift features in a more scalable way.

For instance, let’s start by initializing a view model with a network provider as a dependency.

The issue we might notice is what if we have more dependencies, such as Analytics, a repository, or another A/B testing provider?

We start to notice that this can become complicated very quickly. Perhaps we could improve the solution by using a “type alias” and nesting our protocols.

Alright, that’s a bit better. Now, let’s create our Dependency Container and see how we can use it.

This is one of the most basic solutions for injecting our dependencies into our view model, but as you may have noticed, it is very problematic.

Some challenges and pitfalls

Let’s dig into some of the issues with this approach.

Firstly, it requires a structure that encourages creating a lot of boilerplate code. You need to create numerous protocol providers to expose your dependencies.

All objects that need some dependency must be created inside the Dependency Container, which implements all providers. This becomes a problematic pattern because it hides your object dependencies (e.g., ViewModel(dependencies: self)) and encourages you to add their creation inside the Dependency Container.

So if you have a ViewModel, a coordinator, or a repository with dependencies, their creation will likely be inside the Dependency Container.

As a result, the Dependency Container will be responsible for storing all dependencies and managing the creation of most objects that use those dependencies.

Lastly, testing becomes cumbersome. Since there is no way to swap dependencies in the Dependency Container, you have to create another implementation that provides those dependencies or, even worse, all the dependencies specified in the "type alias". Consequently, your view model will likely have a ViewModelDependencyContainerMock that implements all protocols, and you’ll need similar mocks for your coordinator, repository, or any other class that uses protocol aggregation via the "typealias".

Refactoring time

Alright, let’s start refactoring our Dependency Container. We’ll begin by adding a way to store our dependencies using a dictionary. We’ll also implement a method to register new dependencies and a way to resolve those dependencies whenever we need them.

Our dictionary will use an ObjectIdentifier as the key, which is a unique identifier for a class instance, which will correspond to our protocol type. The value in the dictionary will be a closure that returns any and it will be responsible for creating our instances.

We’ve also added a dispatch queue class, which is used for managing the execution of tasks that can run in parallel. This is particularly useful in scenarios where you have multiple independent threads that can read dependencies concurrently, but we need to limit the scope when writing to those dependencies.

That’s why, when registering, we use the barrier flag dependencyQueue.sync(flags: .barrier). Providing a useful way for managing thread-safe access to shared resources, particularly for write operations. However, there is no issue when accessing the resources, so there’s no need to add the barrier flag when resolving the dependency.

With that refactor, we’ve provided a solution to most of our problems, offering a way to register different types of dependencies that we can easily swap for testing purposes, and a method to resolve them. Our dependency container is becoming more robust, but there is still room for improvement.

However, if we forget to register any dependency, it will return a fatal error. This is expected since you should handle registration for all necessary cases.

Leveraging new Swift features

We still need to manually call the resolver for each dependency, and that involves some repetitive work.

A good way to solve the repetition is to make use of @propertyWrappers. With that approach, we can easily declare dependencies similar to what SwiftUI does with @EnvironmentObject.

Don’t forget to call the registration method in your app’s root, or in the Scene or AppDelegate.

After our refactor in the Dependency Container, your tests will be easier to write and manage.

Something we could improve is to provide a way to access all available dependencies. A nice way to provide that is to implement a keyPath and subscript for easy access.

This structure primarily acts as a dependency resolver, enabling us to reference dependencies using the key path accessor, as shown in the example: @Dependency(\.networkProvider).

We use a computed property to ensure that we reference the same dependency everywhere. Updating the dependency directly through the static subscript DependencyContainer or by using the property wrapper's wrapped value which results in the same source being updated. As a bonus, you have access to all dependencies that were registered.

Final thoughts

Wrapping up, this is one possible approach for dealing with dependency injection in Swift using a container-based structure. It’s important to note that the solution may not fit all cases but it is a good starting point.

Be mindful of what you’re adding to the container to prevent a scenario where you could end up throwing everything inside just for the sake of convenience.

In larger projects, you may need to consider even more complex cases when creating your own Dependency Container. You could also choose a more robust solution from the community, such as Factory, RouterService, or dependencies from PointFree. These solutions offer more comprehensive tools and patterns that can better handle the complexities of large-scale projects.

I hope you’ve enjoyed reading this article. You can find me on Linkedin or Instagram.

--

--