Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 033 / 100

Dependency injection in Swift for Swift & iOS developers

Swap implementations for tests and previews.

Expona Admin8 min read

Dependency injection in Swift for Swift & iOS developers

Testing becomes painful when your production code is tightly coupled to concrete implementations. Mock services feel impossible to inject when everything reaches out to singletons or static constructors. We've all hit this wall: you write a view that fetches data from a real API, and now your previews hang or your unit tests wait for network responses. Dependency injection in Swift solves this by letting you swap implementations for tests and previews.

Why dependency injection matters

When we build features that depend on external services—network requests, database queries, file I/O—our code becomes difficult to test in isolation. A view that creates its own network client, or a service that reaches out to a global UserDefaults instance, locks us into a single implementation path. We can't easily provide a mock during tests, and we can't preview UI without side effects.

Dependency injection (DI) inverts this relationship. Instead of a component creating or finding its dependencies, we pass them in. The component receives what it needs; it doesn't care where it came from. This decoupling gives us freedom: use the real database in production, a fast in-memory store in tests, and a deterministic mock in previews.

Initializer injection with protocols

The simplest and most explicit form of DI in Swift is initializer injection. You define a protocol that describes what your dependency must do, then accept an instance of that protocol in your initializer. The caller decides which concrete type to provide.

Here's a practical example: a weather service that fetches current conditions.

swift
protocol WeatherProvider {
    func fetchWeather(for city: String) async throws -> WeatherData
}

class ProductionWeatherProvider: WeatherProvider {
    private let baseURL = URL(string: "https://api.weather.service/v1")!
    
    func fetchWeather(for city: String) async throws -> WeatherData {
        var request = URLRequest(url: baseURL.appending(queryItems: [
            URLQueryItem(name: "q", value: city)
        ]))
        let (data, _) = try await URLSession.shared.data(for: request)
        return try JSONDecoder().decode(WeatherData.self, from: data)
    }
}

class WeatherViewModel: ObservableObject {
    @Published var weather: WeatherData?
    @Published var error: String?
    
    private let weatherProvider: WeatherProvider
    
    init(weatherProvider: WeatherProvider) {
        self.weatherProvider = weatherProvider
    }
    
    @MainActor
    func loadWeather(for city: String) async {
        do {
            self.weather = try await weatherProvider.fetchWeather(for: city)
        } catch {
            self.error = error.localizedDescription
        }
    }
}

In production, you create the view model with a real provider. In tests or previews, you pass a mock instead. The view model has no idea which implementation it's using—it only cares that the object conforms to WeatherProvider.

swift
// Production
let weatherProvider = ProductionWeatherProvider()
let viewModel = WeatherViewModel(weatherProvider: weatherProvider)

// Testing
class MockWeatherProvider: WeatherProvider {
    var shouldFail = false
    var mockData = WeatherData(temp: 72, condition: "Sunny")
    
    func fetchWeather(for city: String) async throws -> WeatherData {
        if shouldFail {
            throw NSError(domain: "MockError", code: 1)
        }
        return mockData
    }
}

let mockProvider = MockWeatherProvider()
let viewModel = WeatherViewModel(weatherProvider: mockProvider)

This approach scales well across your codebase. Each class or struct receives its dependencies explicitly. It's clear from the initializer signature exactly what this component needs to operate. The main downside: you end up passing dependencies through many layers, which can feel verbose. That's where SwiftUI's environment helps.

Environment values for SwiftUI

SwiftUI provides a cleaner way to distribute dependencies across view hierarchies using @Environment and custom environment values. Instead of passing mock services through initializers down the view tree, you inject them at the root and let views below access them implicitly.

swift
struct WeatherEnvironmentKey: EnvironmentKey {
    static let defaultValue: WeatherProvider = ProductionWeatherProvider()
}

extension EnvironmentValues {
    var weatherProvider: WeatherProvider {
        get { self[WeatherEnvironmentKey.self] }
        set { self[WeatherEnvironmentKey.self] = newValue }
    }
}

Now any view in the hierarchy can access the weather provider without it being passed through intervening views.

swift
struct WeatherView: View {
    @Environment(\.weatherProvider) var weatherProvider
    @State private var weather: WeatherData?
    @State private var isLoading = false
    
    var body: some View {
        VStack {
            if let weather = weather {
                Text("\(weather.temp)°F")
                Text(weather.condition)
            } else if isLoading {
                ProgressView()
            } else {
                Button("Load Weather") {
                    Task {
                        isLoading = true
                        do {
                            weather = try await weatherProvider.fetchWeather(for: "London")
                        } catch {
                            print("Error: \(error)")
                        }
                        isLoading = false
                    }
                }
            }
        }
    }
}

At the app entry point, you inject a specific implementation for the entire view tree. In previews, you override it with a mock.

swift
@main
struct WeatherApp: App {
    var body: some Scene {
        WindowGroup {
            WeatherView()
                .environment(\.weatherProvider, ProductionWeatherProvider())
        }
    }
}

The environment approach reduces initializer bloat and makes it easy for deeply nested views to access services. However, it's less explicit—you can't see from a view's initializer what services it depends on. A balance is to use initializer injection for core dependencies and environment for lower-level services like logging or analytics.

Mock services in previews

A major win of DI is making previews work without network calls or complex setup. With a mock provider, your previews render instantly and deterministically.

swift
class PreviewWeatherProvider: WeatherProvider {
    func fetchWeather(for city: String) async throws -> WeatherData {
        // Simulate delay to show loading state
        try await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
        return WeatherData(temp: 65, condition: "Rainy")
    }
}

#Preview {
    WeatherView()
        .environment(\.weatherProvider, PreviewWeatherProvider())
}

You can also create different preview variants by varying mock behavior. Want to test an error state? Create a mock that throws.

swift
class FailingWeatherProvider: WeatherProvider {
    func fetchWeather(for city: String) async throws -> WeatherData {
        throw URLError(.timedOut)
    }
}

#Preview("Weather Error") {
    WeatherView()
        .environment(\.weatherProvider, FailingWeatherProvider())
}

This pattern makes previews fast, reliable, and representative of real conditions. Your design team can see loading states, error layouts, and edge cases without waiting for a real API response.

Avoid global singletons

A common antipattern in iOS development is relying on global singletons to access services. It feels convenient at first—call APIClient.shared.fetch() from anywhere—but it makes testing and previews nearly impossible.

swift
// Avoid this pattern
class APIClient {
    static let shared = APIClient()
    
    func fetch() async throws -> Data {
        // real network call
    }
}

// In a view, you're stuck with the singleton
struct SomeView: View {
    var body: some View {
        Text("Data: \(APIClient.shared.fetch())")
            // Can't mock this, can't test it easily
    }
}

If APIClient.shared is a singleton, you can't replace it with a mock in tests. Your preview hangs waiting for a network response. Your unit tests become integration tests because they actually hit the API or require elaborate mocking frameworks.

Instead, treat your service instances as dependencies. Pass them in or inject them through the environment. The small added verbosity pays huge dividends in testability.

swift
// Better: pass the service as a dependency
struct SomeView: View {
    let apiClient: APIClient
    
    var body: some View {
        Text("Service injected")
    }
}

// Or use environment
struct SomeView: View {
    @Environment(\.apiClient) var apiClient
    
    var body: some View {
        Text("Service from environment")
    }
}

If you do need a default instance for production use, create it at the app entry point and inject it. Don't hide it behind a static property that your entire codebase comes to depend on.

Common mistakes

Over-engineering with containers: Some developers reach for heavyweight DI containers right away. Swift's strong type system and straightforward initializer syntax usually make explicit passing sufficient. Containers add complexity unless you're managing dozens of interconnected services.

Injecting too broadly: Not every value needs to be injected. Environment variables, constants, and standard library types can be used directly. Only inject abstractions you actually want to mock or swap at runtime.

Forgetting to mock all paths: When you add a new service to your codebase, update all your test mocks and preview providers. A real implementation left behind defeats the purpose of DI.

Using environment for core business logic: Environment values work well for infrastructure (API clients, logging, analytics). For domain-specific logic, initializer injection is clearer. A view model shouldn't reach into the environment for every business service.

Key takeaways

  • Define protocols for your dependencies so implementations can be swapped without changing consumer code.
  • Use initializer injection for explicit, strongly-typed dependencies; it scales well and makes requirements visible.
  • Leverage SwiftUI environment values to avoid passing dependencies through every level of the view hierarchy.
  • Create protocol-conforming mocks for fast, deterministic previews and unit tests.
  • Avoid global singletons; inject service instances at the app entry point instead.
  • Balance explicit passing with environment values to keep code readable and maintainable.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles