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:
- Hidden Dependencies: Classes rely on singletons without explicit indicators, making it harder to track app dependencies and leading to unintended coupling.
- Circular Dependencies: Singletons depending on each other can create cycles, leading to initialization deadlocks as instances wait for each other.
- Testing Challenges: Using singletons complicates testing and SwiftUI previews since singletons can’t easily be swapped for mocks.
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.
Dependency Injection
Table of Contents
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
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.
Why Not Use @EnvironmentObject
?
While @EnvironmentObject
simplifies dependency sharing in SwiftUI, it has limitations:
- It only works with
ObservableObject
- It doesnβt allow using protocols for implementation switching.
- It only works in SwiftUI views hierarchy
- It only checks for service existence when accessed, increasing the risk of unintentional omissions in views.
Dependency injection with property wrappers
This is similar to the @EnvironmentObject
solution where the dependency management happens inside of property wrappers, but without drawbacks.
DependencyContainer
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
}
}
Defining a DI container
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()
}
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()
}
}
}
Injecting Dependencies
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.
- Dependency Existence Checked on Initialization. The property wrapper checks that the service exists when initialized. This helps prevent accidental circular dependencies, where services might try to reference each other. The framework enforces a clear initialization order, encouraging thoughtful dependency management and avoiding deadlocks.
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
Mocking
To swap out implementations for testing or previews, you have two options:
Replace service with mock in DI Container
DI.Container.register(DI.data, DataRepositoryMock()) MainView()
Conclusion
This guide provides a straightforward approach to adopting DI using a