Pass dependencies in, create them in one place
A chef who grows their own vegetables, raises their own chickens and mills their own flour can only cook one way, with whatever their farm produces. A chef who is given ingredients can cook the same recipe with anything that fits: organic eggs, store-bought ones, or plastic ones for a photo shoot.
For code:
- Ask for dependencies in the initializer:
init(api: RecipesAPI, favorites: FavoritesStore). - Depend on a protocol (or a small struct of functions), not a concrete class, so a fake fits in.
- Create the real objects once, in one place at the top of the app, called the composition root. In SwiftUI, that’s your
Appstruct.
Things like the current time, random ids and storage are dependencies too. A view model that calls Date() directly can’t be tested for “a coupon expires after 24 hours”.
Initializer injection is the default: explicit, compiler-checked, and it makes dependency graphs visible. Abstractions can be protocols (familiar, works with existentials or generics) or structs of closures (struct RecipesClient { var fetch: () async throws -> [Recipe] }), which are cheaper to fake per test (override one closure) and avoid protocol-with-associated-types friction. The composition root (the App struct, or an AppDependencies container built there) creates live implementations once, chooses environment-specific ones (UI-test launch arguments, preview mode), and hands them down. Nondeterminism belongs behind dependencies: now: () -> Date or a Clock, uuid: () -> UUID, UserDefaults(suiteName:) or a key-value store protocol. Default parameter values pointing at live singletons (api: RecipesAPI = LiveRecipesAPI.shared) are a pragmatic compromise for migration, but make it easy to forget injecting a fake.
protocol RecipesAPI: Sendable {
func recipes() async throws -> [Recipe]
}
struct LiveRecipesAPI: RecipesAPI {
let session: URLSession
let baseURL: URL
func recipes() async throws -> [Recipe] {
let (data, _) = try await session.data(from: baseURL.appending(path: "recipes"))
return try JSONDecoder().decode([Recipe].self, from: data)
}
}
// A struct of closures: an alternative to a protocol, easy to override per test
struct FavoritesClient: Sendable {
var load: @Sendable () -> Set<Recipe.ID>
var save: @Sendable (Set<Recipe.ID>) -> Void
}
extension FavoritesClient {
static func live(defaults: UserDefaults) -> FavoritesClient {
FavoritesClient(
load: { Set(defaults.stringArray(forKey: "favorites") ?? []) },
save: { defaults.set(Array($0), forKey: "favorites") }
)
}
static let inMemory = FavoritesClient(load: { [] }, save: { _ in })
}
// Everything the app needs, created once
@MainActor
final class AppDependencies {
let recipesAPI: RecipesAPI
let favorites: FavoritesClient
let now: @Sendable () -> Date
init(recipesAPI: RecipesAPI, favorites: FavoritesClient, now: @escaping @Sendable () -> Date) {
self.recipesAPI = recipesAPI
self.favorites = favorites
self.now = now
}
static func live() -> AppDependencies {
AppDependencies(
recipesAPI: LiveRecipesAPI(session: .shared, baseURL: URL(string: "https://api.example.com")!),
favorites: .live(defaults: .standard),
now: { Date() }
)
}
}
// The composition root
@main
struct RecipesApp: App {
@State private var dependencies = ProcessInfo.processInfo.arguments.contains("--ui-testing")
? AppDependencies.uiTesting() // fakes for UI tests
: AppDependencies.live()
var body: some Scene {
WindowGroup {
RecipeListScreen(api: dependencies.recipesAPI, favorites: dependencies.favorites)
}
}
}LiveRecipesAPIis the real implementation; tests and previews use a fake that returns sample recipes.FavoritesClientis a struct of two functions. A test can create one whosesaverecords what was saved, in one line.AppDependencies.live()is the only place that mentionsURLSession.sharedandUserDefaults.standard.- UI tests launch the app with
--ui-testing, and the composition root swaps in fakes, so UI tests don’t depend on the real server.
Tap the line with the bug.
@Observable @MainActor
final class CheckoutViewModel {
private(set) var coupon: Coupon?
private(set) var error: String?
func apply(code: String) async {
do {
let coupon = try await APIClient.shared.coupon(code: code)
guard coupon.expiresAt > Date() else { error = "This coupon has expired."; return }
self.coupon = coupon
UserDefaults.standard.set(code, forKey: "lastCoupon")
} catch {
self.error = "Couldn't apply the coupon."
}
}
}Hint: Which three things does this view model reach for on its own instead of receiving?
The view model finds its dependencies instead of receiving them:
APIClient.shared: every test calls the real server (slow, flaky, needs a network, and can’t simulate errors on cue).Date(): a test with a coupon expiring “today at 23:59” passes in the morning and fails at night on CI.UserDefaults.standard: tests leak state into each other and into the simulator’s real app data.
Receive all three:
@Observable @MainActor
final class CheckoutViewModel {
private(set) var coupon: Coupon?
private(set) var error: String?
private let api: CouponsAPI
private let now: () -> Date
private let lastCoupon: LastCouponStore
init(api: CouponsAPI, now: @escaping () -> Date, lastCoupon: LastCouponStore) {
self.api = api; self.now = now; self.lastCoupon = lastCoupon
}
func apply(code: String) async {
do {
let coupon = try await api.coupon(code: code)
guard coupon.expiresAt > now() else { error = "This coupon has expired."; return }
self.coupon = coupon
lastCoupon.save(code)
} catch {
self.error = "Couldn't apply the coupon."
}
}
}
@MainActor
@Test func expiredCouponIsRejected() async {
let fixedNow = Date(timeIntervalSince1970: 1_800_000_000)
let vm = CheckoutViewModel(
api: FakeCouponsAPI(coupon: Coupon(code: "SPRING", expiresAt: fixedNow - 1)),
now: { fixedNow },
lastCoupon: InMemoryLastCouponStore()
)
await vm.apply(code: "SPRING")
#expect(vm.error == "This coupon has expired.")
#expect(vm.coupon == nil)
}Predict the output
The preview of CheckoutScreen creates CheckoutViewModel with FakeCouponsAPI and now: { fixedDate }. What does the preview need from the network?
With every dependency injected, a preview is just another composition root: it passes fakes with sample data and a fixed date. It renders instantly, offline, in any state you want (expired coupon, error, success), which is often the biggest everyday win of dependency injection on iOS, before tests even come into it.
- Initializer injection by default; abstractions as protocols or structs of closures (cheap per-test overrides).
- One composition root (the
Appstruct or anAppDependenciescontainer) creates live implementations; launch arguments select UI-test fakes. - Time, ids, randomness and storage are dependencies:
now: () -> Date,Clock,uuid: () -> UUID, injected key-value stores. - Default arguments pointing at live singletons are a migration aid, not a design.
- Previews are composition roots too: fakes make them instant and stateful.
Check this part
What is the composition root?
Why inject a now: () -> Date instead of calling Date()?
What's an advantage of a struct of closures over a protocol for a dependency?