As apps grow in complexity, managing dependencies between objects becomes increasingly challenging. One of the key techniques to keep your code flexible, maintainable, and testable is Dependency Injection. In this post, we’ll explore dependency injection in SwiftUI by evolving an example app through three stages:
Direct Instantiation
Injection via Initializers
Using a Dependency Container
We’ll cover the pain points of each approach and why transitioning to dependency injection can help improve your code. By the end, you’ll understand when and why you should adopt each approach and how to implement it.
Phase 1: Direct Instantiation (The Quickest Way)
Let’s start with a simple weather app. We want to simply display a weather description. We will use a view model, data service to get the weather data, and a logger class to simplify debugging.
Below is our code using the direct instantiation method to initialize all the objects our SwiftUI view will need:
With the above in place, our view can easily initialize the WeatherViewModel itself and start using it:
The Pain Point: Tight Coupling and Limited Testing
Although directly instantiating your objects is straightforward and fast, we have coupled the view model, data service, and logger. The view model needs to know how to initialize the data service, and in turn the data service needs to know how to create the logger object.
The graphic below highlights how coupled the three classes are now. If DataService is updated to require a new dependency, the view model will now carry the extra responsibility of ensuring that it gets it.
In this setup, WeatherViewModel directly creates a DataService, and DataService directly creates LoggerService. This creates tight coupling between the three classes, and changing or testing WeatherViewModel becomes challenging. For example, if you want to write a unit test for WeatherViewModel, there’s no way to substitute the DataService with a mock that can return what ever we want.
In the below snippet, DataService will always return "Sunny" as it's hard coded for example purposes, but you can imagine that in practice it would make a real network request, returning the actual current weather, making it impossible to test consistently.
Direct instantiation isn’t scalable or flexible. So, let’s move on to injecting dependencies via initializers.
Phase 2: Dependency Injection via Initializers
To solve the problem of tight coupling and testing difficulties, we can use dependency injection via initializers. This approach allows us to pass dependencies into classes, rather than having classes create their own dependencies.
The difference here as you can see in the code above, is that the view model and the data service get their required objects via the initializer now. With the updated code in place, we can easily mock our DataService to test our WeatherViewModel.
Here is the mock of DataService:
We can test our view model as demonstrated below:
The Pain Point: Repetitive and Manual Wiring
At first glance, initializer injection looks great because we can pass a MockDataService when testing, improving testability. However, as your app scales, manually creating and wiring up each dependency becomes tedious.
Imagine this scenario: Your LoggerService now requires a UserSession object to tag logs for a specific user. Manually instantiating each dependency increases complexity and risk of errors, especially if you introduce additional services.
Your top level code now has to wire up several dependencies like below every time it needs to create a WeatherViewModel:
Take our view code, it now has to initialize several objects, increasing coupling and littering our view with classes it doesn't even use directly:
This setup is starting to show repetitive boilerplate and increased complexity, which makes us look for a centralized solution.