Dependency Injection in Swift: From Manual DI to `@Environment`
The most expensive bug to fix is the one that only happens in production. If your networking layer is hardcoded into your view models, you cannot write fast unit tests that catch it — every test either hits the real network or fails with a timeout. Dependency injection is the pattern that breaks that coupling and makes your code both testable and flexible.
This post walks through four DI patterns you will encounter in a production iOS codebase: constructor injection,
property injection, a protocol-based service locator, and SwiftUI’s @Environment. For each pattern, you will see when
to use it and what it costs. This guide assumes familiarity with protocols and
SwiftUI state management.
Contents
- The Problem: Hardcoded Dependencies
- Pattern 1 — Constructor Injection
- Pattern 2 — Property Injection
- Pattern 3 — Protocol-Based Service Locator
- Pattern 4 — SwiftUI
@Environment - Advanced Usage
- When to Use (and When Not To)
- Summary
The Problem: Hardcoded Dependencies
Here is a FilmDetailViewModel that a junior engineer might write on their first week:
@Observable
@MainActor
final class FilmDetailViewModel {
var film: PixarFilm?
var isLoading = false
var error: Error?
// ❌ Hardcoded dependency — untestable, inflexible
private let api = PixarAPI.shared
func loadFilm(id: UUID) async {
isLoading = true
defer { isLoading = false }
do {
film = try await api.fetchFilm(id: id)
} catch {
self.error = error
}
}
}
The problem is private let api = PixarAPI.shared. This line makes it impossible to:
- Write a unit test that doesn’t hit the real network.
- Run a fast CI build that doesn’t depend on external servers.
- Show realistic data in SwiftUI Previews without a live endpoint.
- Swap in a local stub for demoing the app without an internet connection.
The view model has made a unilateral decision about how it fetches data. In a real production app, this pattern
cascades — every screen that needs data creates its own PixarAPI.shared reference, and you end up with a codebase
where the networking layer is woven invisibly through every feature.
Dependency injection inverts this: instead of the view model creating its dependencies, the dependencies are provided from outside.
Pattern 1 — Constructor Injection
Constructor injection (also called initializer injection) is the gold standard. Dependencies are explicit, required, and visible at the call site.
Start by defining the dependency as a protocol:
protocol FilmRepository: Sendable {
func fetchFilm(id: UUID) async throws -> PixarFilm
func fetchAll() async throws -> [PixarFilm]
func toggleFavorite(_ film: PixarFilm) async throws
}
protocol AnalyticsService: Sendable {
func track(_ event: AnalyticsEvent)
}
Now rewrite FilmDetailViewModel to accept these protocols through its initializer:
@available(iOS 17.0, *)
@Observable
@MainActor
final class FilmDetailViewModel {
var film: PixarFilm?
var isLoading = false
var error: Error?
private let repository: any FilmRepository
private let analytics: any AnalyticsService
// ✅ Dependencies are explicit and injected from outside
init(
repository: any FilmRepository = LiveFilmRepository(),
analytics: any AnalyticsService = LiveAnalyticsService()
) {
self.repository = repository
self.analytics = analytics
}
func loadFilm(id: UUID) async {
isLoading = true
defer { isLoading = false }
do {
film = try await repository.fetchFilm(id: id)
analytics.track(.filmViewed(id: id))
} catch {
self.error = error
}
}
}
The default parameter values (= LiveFilmRepository()) mean that production call sites require zero changes —
FilmDetailViewModel() still works. But test code can substitute a mock:
// In your test suite:
// @unchecked because we only mutate during test setup (single-threaded)
final class MockFilmRepository: FilmRepository, @unchecked Sendable {
var filmToReturn: PixarFilm?
var errorToThrow: Error?
var fetchFilmCallCount = 0
func fetchFilm(id: UUID) async throws -> PixarFilm {
fetchFilmCallCount += 1
if let error = errorToThrow { throw error }
return filmToReturn ?? PixarFilm(id: id, title: "Coco", year: 2017)
}
func fetchAll() async throws -> [PixarFilm] { [] }
func toggleFavorite(_ film: PixarFilm) async throws {}
}
@MainActor
struct FilmDetailViewModelTests {
@Test("loadFilm sets film on success")
func loadFilmSuccess() async {
let mock = MockFilmRepository()
mock.filmToReturn = PixarFilm(id: UUID(), title: "Up", year: 2009)
let viewModel = FilmDetailViewModel(repository: mock)
await viewModel.loadFilm(id: mock.filmToReturn!.id)
#expect(viewModel.film?.title == "Up")
#expect(viewModel.isLoading == false)
#expect(mock.fetchFilmCallCount == 1)
}
@Test("loadFilm surfaces error on failure")
func loadFilmFailure() async {
let mock = MockFilmRepository()
mock.errorToThrow = URLError(.timedOut)
let viewModel = FilmDetailViewModel(repository: mock)
await viewModel.loadFilm(id: UUID())
#expect(viewModel.film == nil)
#expect(viewModel.error != nil)
}
}
These tests run in under a millisecond, never touch the network, and fail deterministically when the view model breaks. That is the promise of constructor injection.
Tip: Use
any FilmRepository(existential) rather than a generic<R: FilmRepository>for view model dependencies. The existential syntax is more ergonomic at initializer call sites and in test code, and the performance difference is immaterial for a single repository instance stored per view model.
Pattern 2 — Property Injection
Property injection sets dependencies through mutable properties rather than the initializer. It is the right tool for optional or reconfigurable dependencies — typically test-only configuration or delegates.
@available(iOS 17.0, *)
@Observable
@MainActor
final class FilmSearchViewModel {
var results: [PixarFilm] = []
var query: String = ""
// Required dependency — constructor injected
private let repository: any FilmRepository
// Optional dependency — property injected
// Defaults to nil; only set in debug/test environments
var logger: ((_ message: String) -> Void)?
init(repository: any FilmRepository = LiveFilmRepository()) {
self.repository = repository
}
func search() async {
logger?("Searching for: \(query)")
guard !query.isEmpty else {
results = []
return
}
do {
results = try await repository.fetchAll().filter {
$0.title.localizedCaseInsensitiveContains(query)
}
logger?("Found \(results.count) results")
} catch {
logger?("Search failed: \(error)")
results = []
}
}
}
Property injection is deliberately second-class here. Required dependencies belong in the initializer — making them
properties would allow the object to exist in an invalid state where repository is not yet set. Reserve property
injection for genuinely optional collaborators that have sensible nil defaults.
Warning: Property injection is a code smell when overused. If a type has three or more property-injected dependencies, that usually signals the type is doing too much or that those dependencies should be constructor-injected instead.
Pattern 3 — Protocol-Based Service Locator
A service locator is a centralized registry that resolves dependencies on demand. It reduces the initializer parameter count for deeply nested types, at the cost of making dependencies implicit.
final class ServiceContainer: @unchecked Sendable {
static let shared = ServiceContainer()
private var registry: [ObjectIdentifier: Any] = [:]
private let lock = NSLock()
func register<T>(_ type: T.Type, factory: @escaping () -> T) {
lock.withLock {
registry[ObjectIdentifier(type)] = factory
}
}
func resolve<T>(_ type: T.Type) -> T {
lock.withLock {
guard let factory = registry[ObjectIdentifier(type)] as? () -> T else {
fatalError("No registration found for \(type). Did you forget to register it?")
}
return factory()
}
}
}
// Registration at app startup:
ServiceContainer.shared.register(FilmRepository.self) {
LiveFilmRepository()
}
// In tests, re-register with a mock before the test runs:
ServiceContainer.shared.register(FilmRepository.self) {
MockFilmRepository()
}
Usage in a view model:
@available(iOS 17.0, *)
@Observable
@MainActor
final class FilmLibraryViewModel {
private let repository: any FilmRepository =
ServiceContainer.shared.resolve(FilmRepository.self)
}
This removes the dependency from the initializer entirely. The tradeoff is that FilmLibraryViewModel’s dependencies
are now invisible — you cannot tell from the init signature what it needs. Testing requires registering mocks in the
service container before the test and tearing them down after, which introduces test ordering concerns.
Use the service locator pattern sparingly — it is most useful in module boundaries, app-level composition roots, or when integrating with legacy code that cannot accept constructor parameters. For feature-level code, constructor injection is almost always cleaner.
Pattern 4 — SwiftUI @Environment
@Environment is SwiftUI’s native DI mechanism. It
propagates values down the view hierarchy without passing them explicitly through every layer. This is the right pattern
for app-wide services like theming, locale, or repositories that many unrelated screens need.
Define a custom environment key and an extension on EnvironmentValues:
import SwiftUI
// Step 1: Define the key with a default value
private struct FilmRepositoryKey: EnvironmentKey {
// Default value used in production
static let defaultValue: any FilmRepository = LiveFilmRepository()
}
// Step 2: Add a typed accessor to EnvironmentValues
extension EnvironmentValues {
var filmRepository: any FilmRepository {
get { self[FilmRepositoryKey.self] }
set { self[FilmRepositoryKey.self] = newValue }
}
}
Inject into the environment at the composition root (typically @main or scene setup):
@main
struct PixarTrackerApp: App {
var body: some Scene {
WindowGroup {
FilmListView()
// Override the default in production:
.environment(\.filmRepository, LiveFilmRepository())
}
}
}
Read the value in a view and pass it to a view model:
struct FilmListView: View {
@Environment(\.filmRepository) private var repository
@State private var viewModel: FilmListViewModel?
var body: some View {
Group {
if let viewModel {
FilmListContentView(viewModel: viewModel)
}
}
.onAppear {
// Bridge @Environment into the view model
viewModel = FilmListViewModel(repository: repository)
}
}
}
For previews and tests, swap the dependency at the injection site:
#Preview("Empty State") {
FilmListView()
.environment(\.filmRepository, MockFilmRepository(films: []))
}
#Preview("Loaded State") {
FilmListView()
.environment(\.filmRepository, MockFilmRepository(films: PixarFilm.previews))
}
Apple Docs:
EnvironmentValues— SwiftUI
The power of @Environment is that it decouples the injection site from the consumption site. Any view in the subtree
can read filmRepository without the intermediate views knowing or caring. The weakness is that dependencies become
invisible unless you read the EnvironmentValues extension — they do not appear in any initializer signature.
Note: Avoid storing
@Observableobjects in customEnvironmentKeydefaultValues. The default value is a static stored property, which means it is shared across the entire app for the lifetime of the process. Use.environment(\.filmRepository, LiveFilmRepository())at the scene level to ensure each scene gets a fresh instance.
Advanced Usage
The @Injected Property Wrapper
The Custom Property Wrappers post covers how to build an @Injected property
wrapper that resolves dependencies from a container automatically:
@propertyWrapper
struct Injected<T> {
private let keyPath: KeyPath<DependencyContainer, T>
var wrappedValue: T {
DependencyContainer.shared[keyPath: keyPath]
}
init(_ keyPath: KeyPath<DependencyContainer, T>) {
self.keyPath = keyPath
}
}
// Usage:
@Observable
final class FilmRatingViewModel {
@Injected(\.filmRepository) private var repository
@Injected(\.analytics) private var analytics
}
This eliminates boilerplate at the call site while keeping the registration centralized. The tradeoff is the same as the service locator — dependencies are implicit.
Factory Pattern for View Model Creation
When a view model requires both injected services and runtime parameters (like a specific PixarFilm selected by the
user), a factory protocol cleanly separates construction from injection:
protocol FilmDetailViewModelFactory {
func make(film: PixarFilm) -> FilmDetailViewModel
}
final class LiveFilmDetailViewModelFactory: FilmDetailViewModelFactory {
private let repository: any FilmRepository
private let analytics: any AnalyticsService
init(repository: any FilmRepository, analytics: any AnalyticsService) {
self.repository = repository
self.analytics = analytics
}
func make(film: PixarFilm) -> FilmDetailViewModel {
FilmDetailViewModel(film: film, repository: repository, analytics: analytics)
}
}
Inject the factory into the parent view model, and call factory.make(film:) when navigation occurs. This avoids
threading the repository and analytics service through every layer of the navigation hierarchy.
Scoped Dependencies and Lifecycle Management
Not every dependency should live for the app’s entire lifetime. A user session object, for example, should be created on login and destroyed on logout. Model this with a separate “session container” that is recreated on each auth state change, rather than clearing and repopulating the global container.
@Observable
@MainActor
final class SessionViewModel {
var sessionContainer: SessionDependencyContainer?
func didLogin(user: User) {
sessionContainer = SessionDependencyContainer(user: user)
}
func didLogout() {
sessionContainer = nil // Destroys all session-scoped dependencies
}
}
Pass sessionContainer into the view hierarchy as an @Environment value scoped to the authenticated portion of your
navigation tree.
When to Use (and When Not To)
| Pattern | Use when | Avoid when |
|---|---|---|
| Constructor injection | Any type with dependencies that matter for testing. The default choice. | The initializer already has more than 5 parameters — consider splitting the type. |
| Property injection | Optional or reconfigurable dependencies (loggers, test delegates). | Required dependencies — the object would be invalid without them. |
| Service locator | Module boundaries, app-level composition root, or legacy code. | Feature-level code where constructor injection is feasible. |
@Environment | App-wide services (theme, locale, repository) shared across many unrelated views. | Single-screen dependencies — adds indirection without benefit. |
The pattern you default to says a lot about your codebase. Teams that default to constructor injection tend to have cleaner, faster test suites. Teams that default to singletons tend to have slow test suites — or no test suites at all.
Summary
- Hardcoded dependencies (
SomeService.shared) make your code untestable. The fix is to define dependencies as protocols and inject them from outside. - Constructor injection is the gold standard: dependencies are explicit, visible at the call site, and trivially swappable in tests.
- Property injection is correct for optional dependencies but is a code smell when overused on required ones.
- A service locator centralizes dependency resolution at the cost of making dependencies implicit. Use it at module boundaries, not throughout feature code.
- SwiftUI’s
@Environmentis the right mechanism for app-wide services that many unrelated views need. Use customEnvironmentKeytypes to keep it type-safe. - Default parameter values in
init(= LiveFilmRepository()) let production code stay concise while tests provide explicit mocks.
With your dependencies properly injected, the next step is putting them to work in well-structured view models.
MVVM in SwiftUI: Practical Patterns with @Observable View Models shows how these two
patterns compose together to produce screens that are both easy to understand and easy to test.