What is dependency injection in Swift?
Published on: October 11, 2024Code has dependencies. It’s something that I consider universally true in one way or another. Sometimes these dependencies are third party dependencies while other times you’ll have objects that depend on other objects or functionality to function. Even when you write a function that should be called with a simple input like a number, that’s a dependency.
We often don’t really consider the small things the be dependencies and this post will not focus on that at all. In an earlier post, I’ve written about using closures as dependencies, also known as protocol witnesses.
In this post I’d like to focus on explaining dependency injection for Swift. You’ll learn what dependency injection is, what types of dependency injection we have, and you’ll learn a bit about the pros and cons of the different approaches.
If you prefer learning through video, take a look here:
Understanding the basics of dependency injection
Dependency Injection (DI) is a design pattern that allows you to decouple components in your codebase by injecting dependencies from the outside, rather than hardcoding them within classes or structs.
For example, you might have a view model that needs an object to load user data from some data source. This could be the filesystem, the networking or some other place where data is stored.
Providing this data source object to your view model is dependency injection. There are several ways in which we can inject, and there are different ways to abstract these dependencies.
It’s fairly common for an object to not depend on a concrete implementation but to depend on a protocol instead:
protocol DataProviding {
func retrieveUserData() async throws -> UserData
}
class LocalDataProvider: DataProviding {
func retrieveUserData() async throws -> UserData {
// read and return UserData
}
}
class UserProfileViewModel {
let dataProvider: DataProviding
// this is dependency injection
init(dataProvider: DataProviding) {
self.dataProvider = dataProvider
}
}
This code probably is something you’ve written at some point. And you might be surprised to find out that simply passing an instance of an object that conforms to DataProviding
is considered dependency injection. It’s just one of several approaches you can take but in its simplest form, dependency injection is actually relatively simple.
Using dependency injection will make your code more modular, more reusable, more testable, and just overal easier to work with. You can make sure that every object you define in your code is responsible for a single thing which means that reasoning about parts of your codebase becomes a lot simpler than when you have lots of complex and duplicated logic that’s scattered all over the place.
Let’s take a closer look at initializer injection which is the form of dependency injection that’s used in the code above.
Initializer injection
Initializer injection is a form of dependency injection where you explicitly pass an object’s dependencies to its initializer. In the example you saw earlier, I used initializer injection to allow my UserProfileViewModel
to receive an instance of an object that conforms to DataProviding
as a dependency.
Passing dependencies around like this is most likely the simplest form of passing dependencies around. It doesn’t require any setup, there’s no third party solutions needed, and it’s all very explicit. For every object you’re able to see exactly what that object will depend on.
More importantly, it’s also a very safe way of injecting dependencies; you can’t create an instance of UserViewModel
without creating and providing your data provider as well.
A downside of this approach of dependency injection is that an object might have dependencies that it doesn’t actually need. This is especially true in the view layer of your app.
Consider the example below:
struct MyApp: App {
let dataProvider = LocalDataProvider()
var body: some Scene {
WindowGroup {
MainScreen()
}
}
}
struct MainScreen: View {
let dataProvider: DataProviding
var body: some View {
NavigationStack {
// ... some views
UserProfileView(viewModel: UserProfileViewModel(dataProvider: dataProvider))
}
}
}
In this example, we have an app that has a couple of views and one of our views needs a ProfileDataViewModel
. This view model can be created by the view that sits before it (the MainView
) but that does mean that the MainView
must have the dependencies that are needed in order to create the ProfileDataViewModel
. The result is that we’re creating views that have dependencies that they don’t technically need but we’re required to provide them because some view deeper in the view hierarchy does need that dependency.
In larger apps this might mean that you’re passing dependencies across several layers before they reach the view where they’re actually needed.
There are several approaches to fixing this. We could, for example, pass around an object in our app that is able to produce view models and other dependencies. This object would depend on all of our “core” objects and is capable of producing objects that need these “core” objects.
An object that’s able to do this is referred to as a factory.
For example, here’s what a view model factory could look like:
struct ViewModelFactory {
private let dataProvider: DataProviding
func makeUserProfileViewModel() -> UserProfileViewModel {
return UserProfileViewModel(dataProvider: dataProvider)
}
// ...
}
Instead of passing individual dependencies around throughout our app, we could now pass our view model factory around as a means of fabricating dependencies for our views without making our views depend on objects they definitely don’t need.
We’re still passing a factory around all over the place which you may or may not like.
As an alternative approach, we can work around this with several tools like the SwiftUI Environment or a tool like Resolver. While these two tools are very different (and the details are out of scope for this post), they’re both a type of service locator.
So let’s go ahead and take a look at how service locators are used next.
Service locators
The service locator pattern is a design pattern that can be used for dependency injection. The way a service locator works is that almost like a dictionary that contains all of our dependencies.
Working with a service locator typically is a two-step process:
- Register your dependency on the locator
- Extract your dependency from the locator
In SwiftUI, this will usually mean that you first register your dependency in the environment and then take it out in a view. For example, you can look at the code below and see exactly how this is done.
extension EnvironmentValues {
@Entry var dataProvider = LocalDataProvider()
}
struct MyApp: App {
var body: some Scene {
WindowGroup {
MainScreen()
.environment(\.dataProvider, LocalDataProvider())
}
}
}
struct MainScreen: View {
@Environment(\.dataProvider) var dataProvider
var body: some View {
NavigationStack {
// ... some views
UserProfileView(viewModel: UserProfileViewModel(dataProvider: dataProvider))
}
}
}
In this code sample, I register my view model and a data provider object on the environment in my app struct. Doing this allows me to retrieve this object from the environment wherever I want it, so I don't have to pass it from the app struct through potentially several layers of views. This example is simplified so the beneifts aren't huge. In a real app, you'd have more view layers, and you'd pass dependencies around a lot more.
With the approach above, I can put objects in the environment, build my view hierarchy and then extract whatever I need at the level where I need it. This greatly simplifies the amount of code that I have to write to get a dependency to where it needs to be and I won't have any views that have dependencies that they don't technically need (like I do with initializer injection).
The downside is that this approach does not really give me any compile-time safety.
What I mean by that is that if I forget to register one of my dependencies in the environment, I will not know about this until I try to extract that dependency at runtime. This is a pattern that will exist for any kind of service load configuration use, whether it's a SwiftUI environment or a third-party library like Resolver.
Another downside is that my dependencies are now a lot more implicit. This means that even though a view depends on a certain object and I can see that in the list of properties, I can create that object without putting anything in its environment and therefore getting crashes when I try to grab dependencies from the environment. This is fine in smaller apps because you're more likely to hit all the required patterns while testing, but in larger apps, this can be somewhat problematic. Again, we're lacking any kind of compile-time safety, and that's something that I personally miss a lot. I like my compiler to help me write safe code.
That said, there is a time and place for service locators, especially for things that either have a good default value or that are optional or that we inject into the app root and basically our entire app depends on it. So if we would forget, we'd see crashes as soon as we launch our app.
The fact that the environment or a dependency locator is a lot more implicit also means that we're never quite sure exactly where we inject things in the environment. If the only place we inject from is the abstract or the root of our application, it's pretty manageable to see what we do and don't inject. If we also make new objects and inject them in the middle of our view hierarchy, it becomes a lot trickier to reason about exactly where a dependency is created and injected. And more importantly, it also doesn't really make it obvious if at any point we overwrite a dependency or if we're injecting a fresh one.
This is something to keep in mind if you choose to make heavy use of a service locator like the SwiftUI environment.
In Summary
In short, dependency injection is a complicated term for a relatively simple concept.
We want to get dependencies into our objects, and we need some mechanism to do this. iOS historically doesn't do a lot of third-party frameworks or libraries for dependency injection, so most commonly you'll either use initializer injection or the SwiftUI environment.
There are third-party libraries that do dependency injection in Swift, but you most likely don’t need them.
Whether you use initializer injection or the service locator pattern, it's somewhat of a mix between a preference and a trade-off between compile-time safety and convenience.
I didn't cover things like protocol witnesses in this post because that is a topic that uses initializer injection typically, and it's just a different kind of object that you inject. If you want to learn more about protocol witnesses, I do recommend that you take a look at my blog post where I talk about using closures as dependencies.
I hope you enjoyed this post. I hope it taught you a lot about dependency injection. And do not hesitate to reach out to me if you have any questions or comments on this post.