Dependency Injection in SwiftUI
Dependency injection (DI) is the practice of supplying an object's collaborators from the outside rather than having it construct them internally. In SwiftUI this matters because views are cheap, frequently-recreated value types, and any service or view model they depend on — a network client, a persistence layer, an authentication manager — needs to be handed in explicitly so it can be swapped for a fake or mock in tests and previews. Without DI, views end up hard-wiring URLSession.shared or a singleton database, which makes them impossible to unit test in isolation and forces every preview to hit real network or disk resources.
Cricket analogy: Just as a franchise wouldn't force a batsman to only ever face throwdowns from one specific bowling machine, a well-designed view shouldn't hard-wire URLSession.shared internally — supplying the network client from outside lets you swap in a practice bowler, a mock, for nets sessions, or tests.
Initializer Injection
The simplest and most explicit form of DI is passing dependencies through a view's or view model's initializer. This makes the dependency visible at the call site and lets the compiler enforce that every consumer provides one. It is the preferred approach for a view model's core dependencies because it keeps the contract obvious and avoids the implicit, stringly-typed lookups that environment-based DI can encourage when overused.
Cricket analogy: Just as a team sheet must list every player's role explicitly before the toss so no one is left uncertain, passing a dependency through a view model's initializer makes the requirement visible and lets the compiler enforce that every consumer supplies one.
protocol UserRepository {
func fetchUser(id: String) async throws -> User
}
final class LiveUserRepository: UserRepository {
func fetchUser(id: String) async throws -> User {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}
}
@Observable
final class ProfileViewModel {
private let repository: UserRepository
var user: User?
init(repository: UserRepository) {
self.repository = repository
}
func load(userId: String) async {
user = try? await repository.fetchUser(id: userId)
}
}
struct ProfileView: View {
@State private var viewModel: ProfileViewModel
init(repository: UserRepository = LiveUserRepository()) {
_viewModel = State(initialValue: ProfileViewModel(repository: repository))
}
var body: some View {
Text(viewModel.user?.name ?? "Loading…")
.task { await viewModel.load(userId: "42") }
}
}Environment-Based Injection
For dependencies that are needed broadly across a view hierarchy — theming, a shared session object, feature flags — SwiftUI's environment is a better fit than threading a parameter through every initializer. You define a custom EnvironmentKey (or, in modern Swift, an @Entry macro-generated key), give it a default value, and inject a real implementation with .environment(_:) near the root of the app. Descendant views read it via @Environment without every intermediate view needing to know it exists.
Cricket analogy: Just as a stadium's pitch conditions are broadcast to every player on the field without the captain personally briefing each one, SwiftUI's environment injects a shared session object near the app's root with .environment(_:), and descendant views read it via @Environment without explicit passing.
@Observable
final class SessionManager {
var isAuthenticated = false
}
private struct SessionManagerKey: EnvironmentKey {
static let defaultValue = SessionManager()
}
extension EnvironmentValues {
var sessionManager: SessionManager {
get { self[SessionManagerKey.self] }
set { self[SessionManagerKey.self] = newValue }
}
}
struct RootView: View {
@State private var session = SessionManager()
var body: some View {
ContentView()
.environment(\.sessionManager, session)
}
}A useful mental model: initializer injection is for a view's *required, view-specific* dependencies (its view model, its data source), while the environment is for *ambient, cross-cutting* dependencies that many unrelated views need without being coupled to how they got there — much like how a building's electrical wiring is 'injected' into every room rather than each appliance carrying its own generator.
Overusing @EnvironmentObject/@Environment for everything turns dependencies invisible: a view can silently crash at runtime if an ancestor forgot to inject a required environment object, because the compiler cannot verify environment values the way it verifies initializer parameters. Reserve the environment for genuinely shared, app-wide concerns, and prefer initializers for anything a view cannot function without.
Protocol-Oriented DI for Testability
The real payoff of DI shows up in tests and previews. Because ProfileViewModel depends on the UserRepository protocol rather than the concrete LiveUserRepository, a test can inject a MockUserRepository that returns canned data instantly, with no network involved. The same technique lets a SwiftUI #Preview render a fully populated screen without a backend, which is invaluable for iterating on UI.
Cricket analogy: Just as a batsman practices against a bowling machine set to simulate Jasprit Bumrah's yorkers before facing him live, injecting a MockUserRepository into ProfileViewModel lets tests run against canned data instantly, with no real network needed.
- Dependency injection supplies a view or view model's collaborators from outside rather than letting it construct them internally.
- Initializer injection is explicit and compiler-checked; it is the right choice for a view model's required dependencies.
- Environment injection (
@Environment,.environment(_:)) suits ambient, cross-cutting dependencies shared across many views. - Coding to a protocol instead of a concrete type is what makes DI valuable — it lets you substitute mocks in tests and previews.
- Overusing the environment hides dependencies and can produce runtime crashes when an ancestor forgets to inject a value.
- DI is what makes
@Observableview models unit-testable without a live network or database.
Practice what you learned
1. What is the primary testability benefit of injecting a `UserRepository` protocol into a view model instead of instantiating `LiveUserRepository` directly?
2. Which SwiftUI mechanism is best suited for a dependency needed broadly across many unrelated views, such as a shared session manager?
3. What is a key risk of relying heavily on `@Environment` for dependencies rather than initializer injection?
4. In the `ProfileView` example, why does the initializer provide a default argument `repository: UserRepository = LiveUserRepository()`?
5. Which of the following is the most appropriate use of initializer injection versus environment injection?
Was this page helpful?
You May Also Like
ViewModel Basics in SwiftUI
A hands-on look at how to actually write, own, and wire up a ViewModel class in a SwiftUI app, using @Observable and @MainActor correctly.
MVVM in SwiftUI
Understand how the Model-View-ViewModel pattern maps onto SwiftUI's declarative views, when it earns its complexity, and how it differs from plain @State-driven views.
@EnvironmentObject Basics
Learn how @EnvironmentObject injects a shared model into the SwiftUI view hierarchy so deeply nested views can access it without manual parameter passing.
Unit Testing ViewModels in Swift
Learn how to write focused, reliable unit tests for SwiftUI ViewModels using XCTest or Swift Testing, including async code and dependency injection.