This article outlines how to transition from singletons to dependency injection in a SwiftUI app with minimal effort.
If you're here, you likely already know that you should avoid singletons with various of reasons like:
Lack of Flexibility: Singletons make it difficult to provide different implementations for different environments (e.g., live vs. mock versions for testing).
And many others.
To overcome most issues we can use Dependency injection (DI). Using it, swapping in a new implementation becomes much easier, as components don’t rely on a single shared instance but on protocols that can be implemented in different ways. This promotes flexibility, easier testing, and adaptability as app requirements evolve.
Constructor injection is a straightforward technique for injecting dependencies by passing them through initializers. While effective, it often increases the size and complexity of initializers.
A common challenge with constructor injection is passing dependencies through a deep UI hierarchy. If a dependency is only needed on a specific screen but must be passed down through multiple intermediary screens, this can quickly become cumbersome.
One way to handle this is by using the Coordinator pattern, which centralizes navigation and manages dependencies more efficiently. Check out this article to learn how to make it.
@EnvironmentObject
?While @EnvironmentObject
simplifies dependency sharing in SwiftUI, it has limitations:
ObservableObject
This is similar to the @EnvironmentObject
solution where the dependency management happens inside of property wrappers, but without drawbacks.
This small library contains only a couple files, can be copied directly into your project or integrated via SPM.
Please, make sure you put a star on it if you like it.
Here is the
Let’s create an example of the service we need to pass as a dependency.
As we are assuming that we are going to make a mock for testing, we need to implement our service as a protocol.
Also we make it as ObservableObject
, to review additional features of the framework.
import Combine
protocol DataRepository: Actor, ObservableObject {
func fetchData() async throws
}
actor DataRepositoryImp: DataRepository {
func fetchData() async throws {
// do real stuff
}
}
actor DataRepositoryMock: DataRepository {
func fetchData() async throws {
// do mocked stuff
}
}
Once integrated, you’ll set up a DI container to manage your services. Each service requires a unique key. You have no limitations in creating keys with the same service type.
import DependencyContainer
extension DI {
static let data = Key<any DataRepository>()
}
Next let’s create a function for registering service in DI Container.
extension DI.Container {
static func setup() {
register(DI.data, DataRepositoryImp())
}
}
Then, call this function at app startup.
@main
struct ExampleApp: App {
@MainActor final class AppState: ObservableObject {
init() {
DI.Container.setup()
}
}
@StateObject var state = AppState()
var body: some Scene {
WindowGroup {
MainView()
}
}
}
The actual injection is done with several property wrappers. The most common one is @DI.Static
. You just need to pass a key we defined earlier.
struct MainView: View {
@DI.Static(DI.data) var data
var body: some View {
Color.clear.task {
try? await data.fetchData()
}
}
}
The points we need to make attention to:
No Need to Specify Type. As you may have noticed we don’t need to write a type here, because Swift gets the type from the key.
It’s very convenient in case you start with a concrete type for your service and later decide to use a protocol to enable mocking. You only need to update the type in the key definition. This change propagates automatically without requiring updates to any code referencing that service.
Additional property wrappers:
@DI.Observed
Use this in SwiftUI views when your service is ObservableObject
. Views will be updated when the service is changed.
struct MainView: View {
//updates the view when data is posting objectWillChange
@DI.Observed(DI.data) var data
var body: some View {
Color.clear.task {
try? await data.fetchData()
}
}
}
@DI.RePublished
Should be used in ObservableObject
. If your service is ObservableObject
, the update will be republished further by the owner of the property wrapper.
struct MainView: View {
final class State: ObservableObject {
// Republishes the data.objectWillChange to the State.objectWillChange
// The view will be updated
@DI.RePublished(DI.data) var data
}
@StateObject var state = State()
var body: some View {
Color.clear.task {
try? await data.fetchData()
}
}
}
In addition, if you have nested services you can use a key path in property wrappers. In this case the observing logic uses the service referenced by a keypath.
@DI.Static(DI.data, \.innerService) var innerService
@DI.Observed(DI.data, \.innerService) var innerService
@DI.RePublished(DI.data, \.innerService) var innerService
To swap out implementations for testing or previews, you have two options:
Initialize with mocked dependency
MainView(data: .init(value: DataRepositoryMock()))
Replace service with mock in DI Container
DI.Container.register(DI.data, DataRepositoryMock())
MainView()
This guide provides a straightforward approach to adopting DI using a