Dependency Injection in SwiftUI: Initializer, Environment, or Container

Dependency injection means the view model gets its dependencies from outside, and whoever creates the view model decides which service to pass. In SwiftUI, that “whoever” is usually a view. So where does the view get the service from, and how can a preview give it a mock instead?

In this post, I’ll show how dependency injection in SwiftUI works as an app grows, with one example that goes from a single screen to a full app. Passing the service through the view’s initializer works for a single screen, but breaks down once the screen is used inside other views. That’s why I put services into the SwiftUI environment, which also makes previews and UI tests easier.

Starting Simple: Passing the Service Through the View’s Initializer

First, the setup. It’s the same as in Dependency Injection in Swift: a protocol for the service, a live implementation, a mock, and a view model that gets the service through its initializer. You can read how to set this up in more detail in this blog post.

nonisolated protocol ProductService: Sendable {
    func fetchProducts() async throws -> [Product]
}

nonisolated struct LiveProductService: ProductService {
    @concurrent
    func fetchProducts() async throws -> [Product] { /* request, decode */ }
}

nonisolated struct MockProductService: ProductService {
    var products: [Product] = []

    func fetchProducts() async throws -> [Product] { products }
}

@MainActor
@Observable
final class ProductViewModel {
    private(set) var state: LoadingState<[Product]> = .idle
    private let productService: ProductService

    init(productService: ProductService) {
        self.productService = productService
    }

    func load() async {
        state = .loading
        do {
            state = .loaded(try await productService.fetchProducts())
        } catch {
            state = .failed("Couldn't load products.")
        }
    }
}

We start with a single screen that shows the products. The view creates its view model in @State, and the service is written right there:

struct ProductsScreen: View {
    @State private var viewModel = ProductViewModel(productService: LiveProductService())

    var body: some View {
        ProductListView(state: viewModel.state)
            .task { await viewModel.load() }
    }
}

#Preview {
    ProductsScreen() // calls the real API
}

The view model itself is fine: it takes a protocol in its initializer. But the view hard-codes which service to pass, so the preview always calls the real API. Without a network connection, it shows an error; with a slow one, a spinner.

The fix is the same idea one level up: the view gets the service through its own initializer. The live service is the default, so nothing changes for the rest of the app:

struct ProductsScreen: View {
    @State private var viewModel: ProductViewModel

    init(productService: ProductService = LiveProductService()) {
        _viewModel = State(initialValue: ProductViewModel(productService: productService))
    }

    var body: some View {
        ProductListView(state: viewModel.state)
            .task { await viewModel.load() }
    }
}

#Preview {
    ProductsScreen(productService: MockProductService(products: Product.samples))
}
  • The app keeps calling ProductsScreen(). Only the preview passes the mock.
  • _viewModel = State(initialValue:) sets the initial value of the @State property from inside init. The underscore accesses the property wrapper itself instead of the wrapped value.
  • The initial value is only used the first time. SwiftUI keeps the first view model for as long as the view is on screen. If the parent later re-creates ProductsScreen with a different service, the new one is ignored.
  • Keep the view model’s init cheap. Every time the parent re-creates the ProductsScreen struct, this init runs and creates a new ProductViewModel, which SwiftUI then throws away. That’s fine as long as the initializer only stores its dependencies and doesn’t start any work.

This works as long as the screen is only previewed on its own. As you’ll see next, it breaks down as soon as the screen is used inside other views.

Why Initializer Injection Breaks Down as the App Grows

Now the app grows. ProductsScreen becomes one tab in a RootTabView, next to a FavoritesScreen that works the same way:

struct RootTabView: View {
    var body: some View {
        TabView {
            Tab("Products", systemImage: "bag") {
                ProductsScreen()      // default: live service
            }
            Tab("Favorites", systemImage: "heart") {
                FavoritesScreen()     // default: live service
            }
        }
    }
}

#Preview {
    RootTabView() // both tabs use the real API and the real UserDefaults
}

The preview of ProductsScreen still works, because it passes the mock itself. But the preview of RootTabView creates both screens with their defaults, the live services. There’s no way to reach in and change that from the outside.

With initializer injection, the only fix is to pass the services through the parent:

struct RootTabView: View {
    let productService: ProductService     // not used here, only passed on
    let favoritesService: FavoritesService // not used here, only passed on

    var body: some View {
        TabView {
            Tab("Products", systemImage: "bag") {
                ProductsScreen(productService: productService)
            }
            Tab("Favorites", systemImage: "heart") {
                FavoritesScreen(favoritesService: favoritesService)
            }
        }
    }
}

#Preview {
    RootTabView(
        productService: MockProductService(products: Product.samples),
        favoritesService: MockFavoritesService()
    )
}

This works for one level. Here’s how it gets worse as the app grows:

  • RootTabView takes dependencies it doesn’t use. It only has the services so it can pass them to its children.
  • Every new level adds the same parameters. If ProductsScreen shows a ProductDetailScreen with its own view model, ProductsScreen also needs to pass the service on, even though its own view model already has one.
  • Every new service adds a parameter to every view in the chain. Add a cart service, and RootTabView, ProductsScreen, and every view in between get another parameter.
  • Every view above needs this to be previewable. If one view in the chain uses the default instead of passing the service on, every preview above it calls the real API again.

That’s why I don’t pass services through view initializers. I prefer to put them into the environment.

The Solution: Services in the SwiftUI Environment

The environment solves exactly the problem from the previous section. A value set on a view reaches every view below it, and none of the views in between need to know about it.

dependency injection in swiftui direct through view init or environment

Put the service into the environment

With the @Entry macro, this is one line per service in an extension on EnvironmentValues:

extension EnvironmentValues {
    @Entry var productService: ProductService = LiveProductService()
    @Entry var favoritesService: FavoritesService = LiveFavoritesService()
}
  • The type is the protocol. If it were LiveProductService, you couldn’t put a mock in its place.
  • The default is the live service. If nobody sets a value, every view gets the live service, so the app works without any setup at the root.
  • A live default is safe here because the services are stateless nonisolated Sendable structs. Without an injected value, SwiftUI may create the default more than once, and for a stateless service, extra copies change nothing.

Read it and create the view model

The screen reads the service with @Environment. The environment isn’t available yet when the view’s init runs, so the view model can’t be created there anymore. Instead, it’s an optional and gets created in .task:

struct ProductsScreen: View {
    @Environment(.productService) private var productService
    @State private var viewModel: ProductViewModel?

    var body: some View {
        Group {
            if let viewModel {
                ProductListView(state: viewModel.state)
            } else {
                ProgressView()
            }
        }
        .task {
            if viewModel == nil {
                viewModel = ProductViewModel(productService: productService)
            }
            await viewModel?.load()
        }
    }
}
  • .task runs every time the view appears, for example when the user switches back to this tab. The if viewModel == nil check makes sure the view model is only created the first time.
  • The view model is created exactly once.

RootTabView goes back to having no parameters at all:

struct RootTabView: View {
    var body: some View {
        TabView {
            Tab("Products", systemImage: "bag") {
                ProductsScreen()
            }
            Tab("Favorites", systemImage: "heart") {
                FavoritesScreen()
            }
        }
    }
}

Override it where you need a different service

Now one modifier on any view replaces the service for everything below it:

#Preview("Root") {
    RootTabView()
        .environment(.productService, MockProductService(products: Product.samples))
        .environment(.favoritesService, MockFavoritesService())
}

#Preview("Products") {
    ProductsScreen()
        .environment(.productService, MockProductService(products: Product.samples))
}

It doesn’t matter how deep ProductsScreen sits in the hierarchy. The override on the parent preview reaches it. You only set it on the views that need a different service: in previews, or on one part of the app that should use another service.

Variant: keep the view model non-optional

If you don’t want the optional view model, there’s a middle way. ProductsScreen keeps its initializer from the first example, and the view that shows it reads the environment and passes the service in:

struct RootTabView: View {
    @Environment(.productService) private var productService
    @Environment(.favoritesService) private var favoritesService

    var body: some View {
        TabView {
            Tab("Products", systemImage: "bag") {
                ProductsScreen(productService: productService)
            }
            Tab("Favorites", systemImage: "heart") {
                FavoritesScreen(favoritesService: favoritesService)
            }
        }
    }
}
  • ProductsScreen stays as simple as in the first example, with a non-optional view model.
  • Only the parent touches the environment, and an override on its preview still reaches both tabs.
  • The caveats from the first example come back: the initial value is only used the first time, and the view model’s init must stay cheap.

Why This Matters for UI Tests

The same property pays off again in UI tests. A UI test runs the app in a separate process, so it can’t pass a mock into a view. All it can do is launch the app with arguments.

With initializer injection, the app would have to pass the mock down every chain of views to every screen. With the environment, the app reads the argument and sets the mock once, wherever it should apply. Here it’s set at the root, for the whole app:

@main
struct ShopApp: App {
    private let productService: ProductService

    init() {
        if ProcessInfo.processInfo.arguments.contains("-uiTesting") {
            productService = MockProductService(products: Product.samples)
        } else {
            productService = LiveProductService()
        }
    }

    var body: some Scene {
        WindowGroup {
            RootTabView()
                .environment(.productService, productService)
        }
    }
}

Every screen in the app picks up the mock, and none of them knows it’s running in a UI test. How the test passes the launch argument, and how to set up different scenarios, is the topic of UI Testing SwiftUI Apps With Mock Services.

If only one feature should use the mock, set the same .environment on that feature’s view instead, like the products tab. Everything in that branch gets the mock, and the rest of the app keeps the live service.

Keeping Previews Safe

The live default has one downside. If a preview forgets the override, nothing tells you: the preview uses the live service and calls the real API. With one service that’s easy to remember. With several, every preview has to override every one of them.

A small view extension collects all mocks in one place:

extension View {
    func injectMockServices() -> some View {
        self
            .environment(.productService, MockProductService(products: Product.samples))
            .environment(.favoritesService, MockFavoritesService())
    }
}

#Preview {
    RootTabView()
        .injectMockServices()
}

When you add a new service, you add its mock here once, and every preview that uses injectMockServices() gets it.

To use a live service for one preview and mocks for everything else, set the specific value before the modifier:

#Preview("Real Product API") {
    RootTabView()
        .environment(.productService, LiveProductService()) // must come before injectMockServices()
        .injectMockServices()
}

The order matters because environment values set closer to the view win. The .environment call directly on RootTabView is applied innermost, so it overrides the value that injectMockServices() sets further out.

The Alternative: DI Containers Like Factory

The environment works, but it has a cost. Every screen now has an @Environment property, an optional @State view model, a .task that creates it, and a nil case to handle. With a handful of screens, that’s fine. With many, it’s the same few lines over and over. That’s usually why people reach for a DI container. One of the most popular one in Swift is Factory.

Another option is Point-Free’s swift-dependencies, which takes a similar approach with a @Dependency property wrapper, and is built around task-local values from the start.

What Factory automates

Each dependency is registered once, in an extension on Factory’s Container. The view model then resolves it with the @Injected property wrapper:

import Factory

extension Container {
    var productService: Factory<ProductService> {
        self { LiveProductService() }
    }
}

@MainActor
@Observable
final class ProductViewModel {
    @ObservationIgnored
    @Injected(.productService) private var productService

    private(set) var state: LoadingState<[Product]> = .idle

    func load() async { /* ... */ }
}

The main win: a simpler view

Compare the screen with the environment version from above. With Factory, the view model gets its service on its own, so the view just creates it:

struct ProductsScreen: View {
    @State private var viewModel = ProductViewModel()

    var body: some View {
        ProductListView(state: viewModel.state)
            .task { await viewModel.load() }
    }
}

No @Environment property, no optional view model, no nil case, and no creating the view model in .task. And that’s on every screen.

Overrides for previews

Instead of .environment overrides, you register a different version. Here are the same examples as above, with Factory.

The parent preview:

#Preview {
    let _ = Container.shared.productService.register {
        MockProductService(products: Product.samples)
    }
    RootTabView()
}

Resolving the whole dependency graph

The bigger benefit shows up when dependencies depend on each other. The view model needs a product service, the service needs an APIClient, and the APIClient needs the token store that holds the current auth token. Without a container, someone has to create all of these in the right order and pass each one into the next.

With Factory, each dependency is registered once, together with what it needs:

extension Container {
    var tokenStore: Factory<TokenStore> {
        self { LiveTokenStore() }.singleton
    }

    var apiClient: Factory<APIClient> {
        self { APIClient(tokenStore: self.tokenStore()) }
    }

    var productService: Factory<ProductService> {
        self { LiveProductService(apiClient: self.apiClient()) }
    }
}

When the view model asks for productService, Factory resolves the whole chain. .singleton makes sure the whole app shares one token store. How to build the same graph without a framework is the topic of Building the Dependency Graph in SwiftUI.

Where it gets harder

  • Parallel tests. Registrations live in one shared container, so tests that run at the same time can overwrite each other’s mocks. To keep them separate, each test gets its own container through a @TaskLocal. The drawback is making that work reliably: task-local values only reach code that runs inside the test’s task. Point-Free put a lot of work into this in their swift-dependencies library, and I’m still not sure I’d catch every case. I’d rather not spend my time hunting test failures that only show up sometimes.
  • Hidden dependencies. Dependencies no longer show up in the initializer. Nothing reminds you to register a mock, so it’s easy to forget one, for example in a preview, and silently call the live service.
  • Multi-window apps. Factory’s scopes are app-wide, not per window. A repository, an object that holds state below the UI like an image cache, is shared by every window if it’s a singleton. On iPad and Mac, each window often needs its own repository, with its own cache or selection, while the token store stays one per app. With the environment, this comes for free: each window has its own view tree, so a repository created inside the WindowGroup content exists once per window. With Factory, you need a custom scope or have to pass a window identifier around. More on per-window lifetimes in Building the Dependency Graph in SwiftUI.

My take

I show DI frameworks as an option, not a recommendation. They look easy at the start: register a dependency, add @Injected, done. The problems come later. If the scopes and overrides aren’t set up right, you get shared state you didn’t expect, like the multi-window case above, and the dependencies no longer show up in the initializer. And once the whole app resolves its dependencies through the framework, moving away from it means touching every view model.

The initializer and environment approach needs more code, but it’s plain Swift and SwiftUI that you can read, understand, and change.

Outlook: MVVM-C

There’s one more way to look at this. With coordinators, views no longer create their view models at all. The coordinator builds each view model with plain initializer injection and passes the dependencies from parent to child coordinator. That replaces most of the environment setup from this post, and gives per-window instances with one coordinator per window.

That’s a bigger topic, so it gets two posts of its own: MVVM-C in SwiftUI: Why, What, and How, and Dependency Injection With MVVM-C.

Summary

ApproachVerdict
Service through the view’s initWorks for a single screen, breaks down once the screen is used inside other views
Service in the environmentWhat I use: reaches screens at any depth, and one override switches previews and UI tests to mocks
DI container like FactoryAn option, with trade-offs: parallel tests, hidden dependencies, and multi-window apps

There’s also a limit. Everything in this post works because the services are stateless, so a live default and extra copies are harmless. A token store or a repository with a cache holds state, and there a live default would quietly create a second instance. Where and how to create those is the topic of Building the Dependency Graph in SwiftUI.

Get the MVVM SwiftUI Cheat Sheet

The cheat sheet puts the whole system on a few pages: what goes in a view model, which view owns it, how to pass it down the tree, common mistakes, and code skeletons you can copy.

Further Reading

Leave a Comment

Subscribe to My Newsletter

Want the latest iOS development trends and insights delivered to your inbox? Subscribe to our newsletter now!

Newsletter Form