Skip to content

Dependency injection and the composition root

Dependency injection means a type receives the collaborators it needs. A composition root is the narrow startup location that chooses concrete live, preview, or test implementations and connects them.

Make the graph visible

struct AppDependencies {
var clock: @Sendable () -> Date
var makeID: @Sendable () -> UUID
var library: any NoteLibrary
var analytics: any Analytics
}
@MainActor
func makeApp(dependencies: AppDependencies) -> AppModel {
let createNote = CreateNote(
makeID: dependencies.makeID,
now: dependencies.clock,
save: dependencies.library.save
)
return AppModel(createNote: createNote, analytics: dependencies.analytics)
}

Production startup supplies a durable library and real clock. Tests supply fixed time, predictable IDs, an in-memory library, and recording analytics. The use case itself does not branch on environment.

Prefer explicit construction

Initializer injection makes required collaborators visible and keeps instances immutable. Method injection suits a value needed for one operation. Environment-based injection can be useful near SwiftUI, but core dependencies should still have clear ownership and defaults that do not silently reach production services.

Avoid mutable service location

A singleton registry hides the dependency graph, permits tests to affect one another, and allows any code to acquire any service. Moving a global behind a protocol changes its spelling, not its lifetime or ownership risk.

The composition root may be framework-specific because startup is framework-specific. The objects it creates can remain independent.

Series navigation

References