What is dependency injection, and how do you do it in an iOS app?
TL;DR
Dependency injection (DI) means: do not create your dependencies inside a type. Receive them from outside. The most common way is initializer injection with a protocol. Benefits: easy testing with fakes, clear dependencies, and easy swapping (for example a staging API).
Without DI
final class OrdersViewModel {
private let api = APIClient() // hard-coded
private let cache = DiskCache.shared // hidden global
}You cannot test this without real network and disk.
Initializer injection (preferred)
protocol OrdersService {
func fetchOrders() async throws -> [Order]
}
@MainActor @Observable
final class OrdersViewModel {
private(set) var orders: [Order] = []
private let service: OrdersService
init(service: OrdersService) {
self.service = service
}
func load() async {
orders = (try? await service.fetchOrders()) ?? []
}
}In tests:
struct FakeOrdersService: OrdersService {
var result: [Order]
func fetchOrders() async throws -> [Order] { result }
}
@Test @MainActor func loadsOrders() async {
let model = OrdersViewModel(service: FakeOrdersService(result: [.sample]))
await model.load()
#expect(model.orders.count == 1)
}The dependencies are clear from the init. The compiler forces you to provide them.
Other kinds of injection
| Kind | How | Use when |
|---|---|---|
| Initializer | init(service:) | Default choice |
| Property | var service: OrdersService! set after init | Storyboard view controllers where you do not control init |
| Method | func load(using service: OrdersService) | The dependency is only needed for one call |
| Environment (SwiftUI) | .environment(...) | Shared app-wide values |
Closures instead of protocols
For small dependencies, a struct of closures is lighter than a protocol:
struct Clock: Sendable {
var now: @Sendable () -> Date
}
extension Clock {
static let live = Clock(now: { Date() })
static func fixed(_ date: Date) -> Clock { Clock(now: { date }) }
}This style is used by the swift-dependencies library from Point-Free.
The composition root
Create real objects in one place near app start, then pass them down.
@main
struct ShopApp: App {
private let api = APIClient(baseURL: Config.apiURL)
var body: some Scene {
WindowGroup {
OrdersScreen(model: OrdersViewModel(service: RemoteOrdersService(api: api)))
}
}
}In UIKit this is often the SceneDelegate or the coordinators.
SwiftUI environment
extension EnvironmentValues {
@Entry var ordersService: any OrdersService = RemoteOrdersService.live
}
struct OrdersScreen: View {
@Environment(\.ordersService) private var service
var body: some View { Text("Orders") }
}
// Preview or test
OrdersScreen().environment(\.ordersService, FakeOrdersService(result: []))DI containers
Libraries like Swinject, Factory or Resolver register and resolve dependencies. They help in very big apps. But they can hide dependencies and move errors from compile time to runtime. Many teams do fine with plain initializers.
Common mistakes
- Injecting a big "god" container everywhere. Inject only what a type needs.
- Using singletons inside types, which hides the dependency.
- Making protocols for everything. Add a protocol when you need a second implementation, such as a fake.
Common follow-up questions
- DI vs service locator? With DI, the object is given its dependencies. With a service locator, the object asks a global registry. DI is clearer.
- How do you inject into a storyboard view controller? Use
instantiateViewController(identifier:creator:)(iOS 13) to call your owninit. - Does DI hurt performance? No. Protocol calls are a little slower, but the cost does not matter for app code.
- How do you keep singletons but still test? Use the singleton as a default argument:
init(api: APIClient = .shared).