iosNudge

What is dependency injection, and how do you do it in an iOS app?

MediumAsked oftenArchitectureTesting 2 min read

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

KindHowUse when
Initializerinit(service:)Default choice
Propertyvar service: OrdersService! set after initStoryboard view controllers where you do not control init
Methodfunc 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 own init.
  • 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).