100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Swift

Dependency Injection in SwiftUI

Learn how to supply view models and services to SwiftUI views through initializers and the environment, keeping views testable and decoupled from concrete implementations.

Architecture PatternsIntermediate10 min readJul 8, 2026
Analogies

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.

swift
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.

swift
@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 @Observable view models unit-testable without a live network or database.

Practice what you learned

Was this page helpful?

Topics covered

#Swift#IOSWithSwiftUIStudyNotes#MobileDevelopment#DependencyInjectionInSwiftUI#Dependency#Injection#SwiftUI#Initializer#StudyNotes#SkillVeris