Skip to content
Commitwise
Menu
iOS Development

Dependency injection

Give iOS code its dependencies instead of letting it reach for singletons, with protocols and initializer injection, a composition root in the App struct, SwiftUI's environment and the @Entry macro, structs of closures as lightweight clients, and libraries such as swift-dependencies, plus the crashes and leaks to avoid.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The checkout view model calls APIClient.shared, reads UserDefaults.standard, asks Date() for the time and UUID() for an order id. It works, and it can’t be tested: every test hits the real server, depends on what an earlier test left in UserDefaults, and gets a different date and id each run. The SwiftUI preview of the checkout screen spins forever, because it’s waiting for the real API.

Dependency injection means code receives the things it depends on from outside instead of creating or finding them itself. The idea is simple; in an iOS app the questions are practical: how to pass things through SwiftUI views, where the real objects are created, and how to swap them in tests and previews.

New to some of these? Open what you need, or skip ahead.

Basics:What is a protocol?

A protocol lists methods and properties a type promises to have, without saying how. Code that depends on protocol RecipesAPI works with the real network client and with a fake that returns sample data. The Classes, inheritance and interfaces tutorial covers protocols.

Basics:What is a singleton?

A singleton is a class with one shared instance that everyone reaches through a static property, like URLSession.shared. Convenient, but code that uses it directly can’t be given anything else. The Singleton pattern tutorial covers when it’s fine and when it hurts.

Part Not started

Pass dependencies in, create them in one place

A chef who grows their own vegetables, raises their own chickens and mills their own flour can only cook one way, with whatever their farm produces. A chef who is given ingredients can cook the same recipe with anything that fits: organic eggs, store-bought ones, or plastic ones for a photo shoot.

For code:

  • Ask for dependencies in the initializer: init(api: RecipesAPI, favorites: FavoritesStore).
  • Depend on a protocol (or a small struct of functions), not a concrete class, so a fake fits in.
  • Create the real objects once, in one place at the top of the app, called the composition root. In SwiftUI, that’s your App struct.

Things like the current time, random ids and storage are dependencies too. A view model that calls Date() directly can’t be tested for “a coupon expires after 24 hours”.

Initializer injection is the default: explicit, compiler-checked, and it makes dependency graphs visible. Abstractions can be protocols (familiar, works with existentials or generics) or structs of closures (struct RecipesClient { var fetch: () async throws -> [Recipe] }), which are cheaper to fake per test (override one closure) and avoid protocol-with-associated-types friction. The composition root (the App struct, or an AppDependencies container built there) creates live implementations once, chooses environment-specific ones (UI-test launch arguments, preview mode), and hands them down. Nondeterminism belongs behind dependencies: now: () -> Date or a Clock, uuid: () -> UUID, UserDefaults(suiteName:) or a key-value store protocol. Default parameter values pointing at live singletons (api: RecipesAPI = LiveRecipesAPI.shared) are a pragmatic compromise for migration, but make it easy to forget injecting a fake.

protocol RecipesAPI: Sendable {
    func recipes() async throws -> [Recipe]
}

struct LiveRecipesAPI: RecipesAPI {
    let session: URLSession
    let baseURL: URL
    func recipes() async throws -> [Recipe] {
        let (data, _) = try await session.data(from: baseURL.appending(path: "recipes"))
        return try JSONDecoder().decode([Recipe].self, from: data)
    }
}

// A struct of closures: an alternative to a protocol, easy to override per test
struct FavoritesClient: Sendable {
    var load: @Sendable () -> Set<Recipe.ID>
    var save: @Sendable (Set<Recipe.ID>) -> Void
}

extension FavoritesClient {
    static func live(defaults: UserDefaults) -> FavoritesClient {
        FavoritesClient(
            load: { Set(defaults.stringArray(forKey: "favorites") ?? []) },
            save: { defaults.set(Array($0), forKey: "favorites") }
        )
    }
    static let inMemory = FavoritesClient(load: { [] }, save: { _ in })
}

// Everything the app needs, created once
@MainActor
final class AppDependencies {
    let recipesAPI: RecipesAPI
    let favorites: FavoritesClient
    let now: @Sendable () -> Date

    init(recipesAPI: RecipesAPI, favorites: FavoritesClient, now: @escaping @Sendable () -> Date) {
        self.recipesAPI = recipesAPI
        self.favorites = favorites
        self.now = now
    }

    static func live() -> AppDependencies {
        AppDependencies(
            recipesAPI: LiveRecipesAPI(session: .shared, baseURL: URL(string: "https://api.example.com")!),
            favorites: .live(defaults: .standard),
            now: { Date() }
        )
    }
}

// The composition root
@main
struct RecipesApp: App {
    @State private var dependencies = ProcessInfo.processInfo.arguments.contains("--ui-testing")
        ? AppDependencies.uiTesting()                      // fakes for UI tests
        : AppDependencies.live()

    var body: some Scene {
        WindowGroup {
            RecipeListScreen(api: dependencies.recipesAPI, favorites: dependencies.favorites)
        }
    }
}
  • LiveRecipesAPI is the real implementation; tests and previews use a fake that returns sample recipes.
  • FavoritesClient is a struct of two functions. A test can create one whose save records what was saved, in one line.
  • AppDependencies.live() is the only place that mentions URLSession.shared and UserDefaults.standard.
  • UI tests launch the app with --ui-testing, and the composition root swaps in fakes, so UI tests don’t depend on the real server.
Spot the bugA coupon test that passes only before midnight

Tap the line with the bug.

@Observable @MainActor
final class CheckoutViewModel {
    private(set) var coupon: Coupon?
    private(set) var error: String?

    func apply(code: String) async {
        do {
            let coupon = try await APIClient.shared.coupon(code: code)
            guard coupon.expiresAt > Date() else { error = "This coupon has expired."; return }
            self.coupon = coupon
            UserDefaults.standard.set(code, forKey: "lastCoupon")
        } catch {
            self.error = "Couldn't apply the coupon."
        }
    }
}

Predict the output

The preview of CheckoutScreen creates CheckoutViewModel with FakeCouponsAPI and now: { fixedDate }. What does the preview need from the network?

  • Initializer injection by default; abstractions as protocols or structs of closures (cheap per-test overrides).
  • One composition root (the App struct or an AppDependencies container) creates live implementations; launch arguments select UI-test fakes.
  • Time, ids, randomness and storage are dependencies: now: () -> Date, Clock, uuid: () -> UUID, injected key-value stores.
  • Default arguments pointing at live singletons are a migration aid, not a design.
  • Previews are composition roots too: fakes make them instant and stateful.

Check this part

  1. What is the composition root?

  2. Why inject a now: () -> Date instead of calling Date()?

  3. What's an advantage of a struct of closures over a protocol for a dependency?

Depend on protocols or small client structs, and receive them through the initializer. Create the real objects once in a composition root (the App struct) and pass them down. Clocks, ids and storage are dependencies too. Previews and tests then pass fakes, and nothing reaches for a singleton.

Part Not started

The SwiftUI environment and DI libraries

Passing every dependency through every initializer gets tiring when a deep view needs something the screens above it don’t. SwiftUI’s environment solves that: you put a value in at the top, and any view below can read it.

  • For an @Observable object: .environment(cart) at the top, @Environment(Cart.self) private var cart in any view below.
  • For other values (a client struct, a setting): declare an environment key with @Entry, then set it with .environment(\.recipesClient, …) and read it with @Environment(\.recipesClient).

The catch: if a view asks for an @Observable object that nobody put in the environment, the app crashes at runtime. Previews need the objects too.

Environment injection is SwiftUI’s built-in, view-scoped DI: values flow down the hierarchy, can be overridden for subtrees (a different client for a sheet, a mock in a preview), and are read lazily by the views that need them. @Observable objects use type-keyed environment(_:); anything else uses EnvironmentValues keys, now declared with the @Entry macro (Xcode 16, back-deploys) with a default value, which also makes missing values impossible (the default is used). Type-keyed object lookups have no default: a missing object is a runtime crash. Environment values are only available inside view bodies, not in view model initializers, so a common pattern is reading them in the screen view and passing them into the view model’s init. For larger codebases, swift-dependencies (Point-Free) provides a global but overridable DependencyValues resolved through task locals, with liveValue, previewValue and testValue, where unimplemented test values fail tests that use them unintentionally; containers like Factory offer a similar registry style. All of them still need discipline about lifetimes (shared vs per-screen instances) and actor isolation (Sendable clients).

// A value dependency with a safe default
extension EnvironmentValues {
    @Entry var recipesClient: RecipesClient = .unimplemented     // previews and tests must set it
}

// A shared, observable object
@Observable @MainActor
final class Cart {
    private(set) var items: [CartItem] = []
    func add(_ recipe: Recipe) { items.append(CartItem(recipe: recipe)) }
}

@main
struct RecipesApp: App {
    @State private var cart = Cart()
    private let client = RecipesClient.live

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(cart)                              // type-keyed object
                .environment(\.recipesClient, client)           // keyed value
        }
    }
}

struct AddToCartButton: View {
    let recipe: Recipe
    @Environment(Cart.self) private var cart                    // crashes if not provided

    var body: some View {
        Button("Add ingredients to cart") { cart.add(recipe) }
    }
}

struct RecipeListScreen: View {
    @Environment(\.recipesClient) private var client
    @State private var viewModel: RecipeListViewModel?

    var body: some View {
        Group {
            if let viewModel { RecipeList(viewModel: viewModel) } else { ProgressView() }
        }
        .task {
            // Environment values are available in body and tasks, not in init
            if viewModel == nil { viewModel = RecipeListViewModel(client: client) }
            await viewModel?.load()
        }
    }
}

#Preview {
    RecipeListScreen()
        .environment(Cart())
        .environment(\.recipesClient, .preview(recipes: .samples))
}

Production puzzles

Predict the output

A new team member adds @Environment(Cart.self) to a row view and runs the app; it works. The SwiftUI preview of that row crashes. Why?

Predict the output

AppDependencies holds a LiveRecipesAPI and is created as a stored property of RecipesApp without @State: let dependencies = AppDependencies.live(). Is that a problem?

Swift note

With Swift 6 strict concurrency, dependency types passed across actors must be Sendable. Clients that are structs of @Sendable closures, and actors or final classes with immutable state, satisfy it; a class wrapping UserDefaults or a mutable cache needs to be an actor, be @MainActor, or use a lock and @unchecked Sendable with care. Marking view models and dependency containers that UI code uses @MainActor avoids most of the friction.

Lifetimes

  • App-wide: the API client, caches, the cart, the session. Created once in the composition root.
  • Per flow: checkout state that should vanish when checkout ends. Created by the flow’s root view or coordinator and dropped with it.
  • Per screen: view models. Owned by the screen with @State.

Most DI bugs in apps are lifetime bugs: a “shared” object created twice (two carts that disagree) or a per-screen object kept forever (a stale view model in a singleton).

Exercisemedium

Remove the singletons

A ProfileViewModel uses AuthManager.shared.currentUser, ImageCache.shared and Analytics.shared.track("profile_opened"). Describe how you’d change it so it can be tested and previewed, and where the real objects would come from.

›

For each singleton, what’s the smallest thing the view model actually needs from it?

SolutionTry it yourself first
  • Replace AuthManager.shared.currentUser with an injected currentUser: User (or a small SessionProvider protocol if it must observe changes).
  • Replace ImageCache.shared with an injected ImageLoading protocol (func image(for: URL) async -> UIImage?); a fake returns a sample image.
  • Replace Analytics.shared with an injected AnalyticsClient struct with a track: (String) -> Void closure; tests record events in an array and assert on them.
  • The composition root (the App, or the screen that creates the view model) passes the real ones; previews and tests pass fakes. The singletons can still exist behind the live implementations, but the view model never mentions them.
  • Environment: type-keyed @Observable objects (missing = fatal error) and @Entry keyed values (with defaults); overridable per subtree.
  • Environment values are readable in body/.task, not in view init: read in the screen, pass into the view model.
  • swift-dependencies: DependencyValues via task locals; liveValue/previewValue/testValue, unimplemented test values fail loudly. Factory-style containers are an alternative.
  • Lifetimes: app-wide in the composition root, per flow in the flow root, per screen via @State. Don’t construct graphs in View stored properties.
  • Swift 6: dependencies crossing actors must be Sendable; @MainActor UI-facing containers.

Check this part

  1. What happens if a view reads @Environment(Cart.self) and no ancestor provided a Cart?

  2. Why can't a view model's initializer read @Environment directly?

  3. Most DI bugs in apps are about…

SwiftUI's environment passes dependencies down the view tree without threading them through every initializer: @Observable objects with .environment(object), values with @Entry keys. Missing objects crash at runtime, so set them at the root and in previews. Libraries like swift-dependencies add a resolved, overridable container with test-safe defaults.

Key takeaways

  • Pass dependencies in through the initializer; don’t reach for singletons.
  • Create the real objects once, in the App struct.
  • Time, ids and storage are dependencies too.
  • The environment passes shared objects down the view tree; previews need them too.
  • Initializer injection with protocols or closure-based clients.
  • One composition root; previews and UI tests are alternative roots.
  • Environment and @Entry for view-scoped DI; swift-dependencies for larger apps.
  • Be explicit about lifetimes and Sendable requirements.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.