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

StyleWhere it shinesWhere it hurts
Constructor injectionPure types, most use casesVerbose when many deps
Property injectionOptional/late-bound depsHides dependencies
Method injectionOne-off usesDoesn’t scale
Factory patternWhen deps need params at creationMore indirection
Service Locator / DIContainerQuick prototypesHides deps, anti-pattern at scale
@Environment (SwiftUI)View-tree-scoped valuesOnly inside SwiftUI
Property wrapper DI (e.g. @Injected)Reduces boilerplateStill 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 @Environment for system services (locale, color scheme) and a custom AppDependencies for 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

  1. “DI requires a framework.” It does not. The simplest DI is init(dep: X).
  2. “Singletons are DI.” They’re the opposite — they remove the choice.
  3. “Property wrappers like @Injected are best practice.” They’re a service locator with cleaner syntax. Same testability cost.
  4. “SwiftUI’s @StateObject is DI.” It’s storage with lifecycle; it doesn’t inject collaborators into the type.
  5. “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 LiveServices and a TestServices factory. Your tests instantiate TestServices() once; your previews instantiate PreviewServices() once.

WARNING: If your init has 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.


Next: 12.5 — The Composable Architecture (TCA)