Skip to main content

Command Palette

Search for a command to run...

Dependency Injection in SwiftUI: Composing Views Without Singletons

Published
5 min read
Dependency Injection in SwiftUI: Composing Views Without Singletons

One of the most common pitfalls in SwiftUI apps is the overuse of singletons and global state. It feels convenient at first — you just reach for UserDefaults.standard, a shared NetworkService.shared, or a global @EnvironmentObject — but it quietly makes your code untestable, hard to reason about, and tightly coupled.

This article shows how to apply Dependency Injection (DI) in SwiftUI to keep your views composable, your ViewModels testable, and your architecture clean.


The Problem: Hidden Dependencies

Let's say you're building a simple favorites feature. A common approach looks like this:

class FavoritesViewModel: ObservableObject {
    @Published var favorites: [Item] = []

    func loadFavorites() {
        // Directly coupled to a concrete singleton
        favorites = FavoritesStore.shared.getAll()
    }
}

And the view:

struct FavoritesView: View {
    @StateObject private var viewModel = FavoritesViewModel()

    var body: some View {
        List(viewModel.favorites) { item in
            Text(item.name)
        }
        .onAppear { viewModel.loadFavorites() }
    }
}

This works at runtime. But try writing a unit test for FavoritesViewModel:

  • You can't control what FavoritesStore.shared returns.

  • Tests become dependent on shared mutable state.

  • Running tests in parallel risks interference between test cases.

The dependency on FavoritesStore.shared is hidden inside the ViewModel. Hidden dependencies are a form of technical debt that compounds over time.


The Solution: Inject Dependencies Through the Initializer

The fix is to invert the dependency: instead of the ViewModel reaching out for what it needs, we give it what it needs.

Start by defining a protocol that abstracts the store:

protocol FavoritesStoreProtocol {
    func getAll() -> [Item]
}

Make the real store conform to it:

class FavoritesStore: FavoritesStoreProtocol {
    static let shared = FavoritesStore()

    func getAll() -> [Item] {
        // real implementation
    }
}

Now inject the dependency into the ViewModel:

class FavoritesViewModel: ObservableObject {
    @Published var favorites: [Item] = []

    private let store: FavoritesStoreProtocol

    init(store: FavoritesStoreProtocol) {
        self.store = store
    }

    func loadFavorites() {
        favorites = store.getAll()
    }
}

The ViewModel no longer knows (or cares) how favorites are retrieved. It only knows what to call.


Making the View Composable

With DI in place, the view needs to receive a configured ViewModel:

struct FavoritesView: View {
    @StateObject private var viewModel: FavoritesViewModel

    init(viewModel: FavoritesViewModel) {
        _viewModel = StateObject(wrappedValue: viewModel)
    }

    var body: some View {
        List(viewModel.favorites) { item in
            Text(item.name)
        }
        .onAppear { viewModel.loadFavorites() }
    }
}

The Composition Root — where you wire everything together — lives at the entry point of your app:

@main
struct FavoritesApp: App {
    var body: some Scene {
        WindowGroup {
            FavoritesView(
                viewModel: FavoritesViewModel(store: FavoritesStore.shared)
            )
        }
    }
}

Notice that FavoritesStore.shared appears only once, at the top. The rest of the codebase never references it directly.


Writing Tests Without Infrastructure

Now you can test the ViewModel in complete isolation with a stub:

class FavoritesStoreStub: FavoritesStoreProtocol {
    var stubbedFavorites: [Item] = []

    func getAll() -> [Item] {
        stubbedFavorites
    }
}
final class FavoritesViewModelTests: XCTestCase {

    func test_loadFavorites_populatesListWithStoredItems() {
        let store = FavoritesStoreStub()
        store.stubbedFavorites = [Item(name: "SwiftUI"), Item(name: "TDD")]
        let sut = FavoritesViewModel(store: store)

        sut.loadFavorites()

        XCTAssertEqual(sut.favorites.map(\.name), ["SwiftUI", "TDD"])
    }

    func test_loadFavorites_withEmptyStore_producesEmptyList() {
        let store = FavoritesStoreStub()
        let sut = FavoritesViewModel(store: store)

        sut.loadFavorites()

        XCTAssertTrue(sut.favorites.isEmpty)
    }
}

These tests are:

  • Fast — no network, no disk, no shared state.

  • Deterministic — the stub always returns what you configure.

  • Isolated — each test controls its own world.


Passing Dependencies Through the View Hierarchy

For deeper view hierarchies, you have two clean options.

Option 1: Explicit Passing (Preferred for Testability)

Pass dependencies through initializers at every level. It's verbose, but the dependency graph is explicit and auditable.

struct RootView: View {
    let store: FavoritesStoreProtocol

    var body: some View {
        NavigationStack {
            FavoritesView(
                viewModel: FavoritesViewModel(store: store)
            )
        }
    }
}

Option 2: @EnvironmentObject with a Protocol Wrapper

When explicit passing becomes impractical (e.g. very deep hierarchies), @EnvironmentObject is acceptable — but inject at the Composition Root, not a singleton:

// Define a concrete container
class AppDependencies: ObservableObject {
    let store: FavoritesStoreProtocol

    init(store: FavoritesStoreProtocol) {
        self.store = store
    }
}

// Inject once at the root
@main
struct FavoritesApp: App {
    var body: some Scene {
        WindowGroup {
            RootView()
                .environmentObject(AppDependencies(store: FavoritesStore.shared))
        }
    }
}

The key difference from a singleton: you control what goes into AppDependencies. In tests, you inject a stub version. No global mutable state.


Previews for Free

A nice side effect of DI: Xcode Previews become trivial to configure.

#Preview {
    let store = FavoritesStoreStub()
    store.stubbedFavorites = [
        Item(name: "Dependency Injection"),
        Item(name: "TDD"),
        Item(name: "Clean Architecture")
    ]
    return FavoritesView(viewModel: FavoritesViewModel(store: store))
}

No more fighting Previews because they hit the real network or real database.


What You Gain

Before (Singleton) After (DI)
Hidden dependencies Explicit, visible dependencies
Hard to test Fully unit-testable
Shared mutable state in tests Each test controls its own world
Previews require real infrastructure Previews use stubs
Tight coupling Views and ViewModels are composable

Summary

Dependency Injection is not a complex pattern — it's simply the discipline of giving a component what it needs rather than letting it reach out for it. In SwiftUI:

  1. Define protocols for your dependencies.

  2. Inject concrete implementations through initializers.

  3. Wire everything at the Composition Root (your @main struct).

  4. Use stubs in tests and previews.

This one practice unlocks testability, composability, and clear architecture — all at once. It's the foundation on which Clean Architecture, the Decorator pattern, and every other technique rely on.

More from this blog