12.4 — Dependency Injection Patterns
Opening scenario
You write a test:
func testCheckoutChargesCard() async {
let vm = CheckoutViewModel()
await vm.checkout()
// …how do I assert the card was charged? It actually called Stripe's API. In a test. Oops.
}
Welcome to the moment every developer realizes hardcoded dependencies are a tax. This chapter is about how to inject collaborators cleanly — in plain Swift, in SwiftUI, and across module boundaries.
Context — DI styles in Swift
| Style | Where it shines | Where it hurts |
|---|---|---|
| Constructor injection | Pure types, most use cases | Verbose when many deps |
| Property injection | Optional/late-bound deps | Hides dependencies |
| Method injection | One-off uses | Doesn’t scale |
| Factory pattern | When deps need params at creation | More indirection |
Service Locator / DIContainer | Quick prototypes | Hides deps, anti-pattern at scale |
@Environment (SwiftUI) | View-tree-scoped values | Only inside SwiftUI |
Property wrapper DI (e.g. @Injected) | Reduces boilerplate | Still hides deps, makes navigation harder |
The first principle
A dependency is anything your type calls that has side effects or external state: networking, storage, system clocks, randomness, analytics. Injection means handing the dependency in from outside instead of constructing it internally.
Constructor injection — the default
struct CheckoutViewModel {
let payments: PaymentsAPI
let analytics: Analytics
let clock: Clock
func checkout(amount: Money) async throws {
analytics.track(.checkoutStarted(amount))
let charge = try await payments.charge(amount, at: clock.now)
analytics.track(.checkoutCompleted(charge.id))
}
}
In tests:
let vm = CheckoutViewModel(
payments: MockPayments(),
analytics: SpyAnalytics(),
clock: FixedClock(date: .testDate)
)
This is the only DI style you need to master. Everything else is sugar or workaround.
Concept → Why → How → Code
Concept: pass collaborators in via the initializer. The compiler enforces that nothing is forgotten.
Why: total testability, no hidden globals, dependency graph readable at the type signature.
How (factory for parameter-time deps):
struct ArticleViewModelFactory {
let articleService: ArticleService
let analytics: Analytics
func make(articleID: UUID) -> ArticleViewModel {
ArticleViewModel(
articleID: articleID,
service: articleService,
analytics: analytics
)
}
}
The Factory carries the singleton-scoped deps; the articleID is supplied at navigation time.
SwiftUI’s @Environment as DI
For values shared down a view tree without explicit threading:
private struct PaymentsKey: EnvironmentKey {
static let defaultValue: PaymentsAPI = LivePaymentsAPI()
}
extension EnvironmentValues {
var payments: PaymentsAPI {
get { self[PaymentsKey.self] }
set { self[PaymentsKey.self] = newValue }
}
}
// At root:
ContentView().environment(\.payments, LivePaymentsAPI(token: tokenFromKeychain))
// In a deep child:
struct CheckoutScreen: View {
@Environment(\.payments) private var payments
var body: some View { /* … */ }
}
Strengths: no prop drilling. Weakness: dependencies are implicit — a screen’s needs aren’t visible at its call site.
@Observable + @Environment in iOS 17+
@Observable final class AppDependencies {
let payments: PaymentsAPI
let analytics: Analytics
let clock: Clock
init(payments: PaymentsAPI, analytics: Analytics, clock: Clock) {
self.payments = payments; self.analytics = analytics; self.clock = clock
}
}
@main struct App: SwiftUI.App {
@State private var deps = AppDependencies(
payments: LivePaymentsAPI(), analytics: LiveAnalytics(), clock: SystemClock()
)
var body: some Scene {
WindowGroup { ContentView().environment(deps) }
}
}
struct CheckoutScreen: View {
@Environment(AppDependencies.self) private var deps
var body: some View { /* deps.payments… */ }
}
Cleanly bundles app-scope deps; previews override one container.
Service locator (the anti-pattern that won’t die)
final class Services {
static let shared = Services()
var payments: PaymentsAPI = LivePaymentsAPI()
}
// Anywhere:
let charge = try await Services.shared.payments.charge(…)
This works, scales to medium codebases, and every large codebase eventually regrets it: dependencies hidden inside method bodies, impossible to fully mock in tests, race conditions on shared mutation.
Use as scaffolding only, never as architecture.
Property wrapper DI
Libraries like Resolver, Factory, Swinject provide @Injected var payments: PaymentsAPI. Internally they’re service locators with sugar. Useful for legacy migration; for greenfield work, prefer plain constructor injection.
In the wild
- Apple’s SwiftUI samples use
@Environmentfor system services (locale, color scheme) and a customAppDependenciesfor app-scoped deps. - Square open-sourced
swift-needle— a compile-time DI graph generator inspired by Java’s Dagger. Used at massive scale. - Most mid-sized iOS shops use constructor injection + a small
Composition Root(single file building the graph at app launch).
Common misconceptions
- “DI requires a framework.” It does not. The simplest DI is
init(dep: X). - “Singletons are DI.” They’re the opposite — they remove the choice.
- “Property wrappers like
@Injectedare best practice.” They’re a service locator with cleaner syntax. Same testability cost. - “SwiftUI’s
@StateObjectis DI.” It’s storage with lifecycle; it doesn’t inject collaborators into the type. - “DI hurts performance.” Modern Swift inlines tiny injections; the overhead is unmeasurable.
Seasoned engineer’s take
Pick constructor injection as your default. Wrap your app-scope dependencies in one container (AppDependencies). Build the graph once in your @main struct. Use @Environment to thread that container through SwiftUI without prop drilling. Avoid singletons and DI frameworks until your codebase justifies them — usually past 100k lines.
TIP: Build a
LiveServicesand aTestServicesfactory. Your tests instantiateTestServices()once; your previews instantiatePreviewServices()once.
WARNING: If your
inithas 10 parameters, that’s not a DI problem — it’s a single responsibility problem. Split the type before reaching for a DI framework.
Interview corner
Junior: “What is dependency injection?” Handing a collaborator (network client, database, clock) into a type from outside, rather than the type constructing it itself. Usually via the initializer.
Mid: “How do you do DI in SwiftUI?”
Constructor injection works for ViewModels. For values shared across the view tree, @Environment with a custom EnvironmentKey (or @Environment(MyDeps.self) for @Observable classes in iOS 17+). I avoid singletons.
Senior: “When would you reach for a DI framework like Swinject or Factory?”
Almost never in greenfield work. Plain constructor injection plus a single AppDependencies container handles most apps. I’d consider a framework only if the codebase already uses one extensively and migrating away would cost more than living with it, or if I needed compile-time-verified graphs across 50+ modules — in which case I’d evaluate swift-needle over a runtime resolver. The risk of any DI framework is hiding the dependency graph behind macros and decorators.
Red-flag answer: “I always use a DI container so my code is decoupled.” Containers don’t decouple — programming against protocols does. The candidate is conflating mechanism with goal.
Lab preview
No dedicated DI lab. Every previous lab and the upcoming Lab 12.2 (modularization) practices constructor injection. The pattern becomes muscle memory through repetition, not through a single exercise.