Dependency injection in Swift: composition, boundaries and testing
Constructor injection, protocol boundaries, composition roots, test doubles and when DI frameworks are unnecessary.
What is dependency injection?
A component receives its collaborators from the outside instead of constructing them. Dependency injection (DI) is the technique; dependency inversion is a design principle. Constructor injection makes mandatory dependencies explicit and makes incorrect initial states harder to represent.
struct Account: Sendable { let id: String }
protocol AccountsRepository: Sendable {
func load() async throws -> [Account]
}
struct LoadAccounts {
let repository: any AccountsRepository
func execute() async throws -> [Account] {
try await repository.load()
}
}Composition root
Create the real repository and its networking collaborators at an app boundary, then inject them into use cases and view models. For a small project, explicit initializer wiring is often clearer than a third-party DI container.
struct APIClient: Sendable { }
struct RemoteAccountsRepository: AccountsRepository {
let api: APIClient
func load() async throws -> [Account] { /* request omitted */ [] }
}
func makeAccountsUseCase() -> LoadAccounts {
let api = APIClient()
return LoadAccounts(repository: RemoteAccountsRepository(api: api))
}Testing and mock semantics
A mock or stub should honor the same success/error contract as production. In particular, don't convert a network error into an empty list in a test double unless the production contract requires it.
struct AccountsStub: AccountsRepository {
let result: Result<[Account], any Error>
func load() async throws -> [Account] { try result.get() }
}
let success = LoadAccounts(repository: AccountsStub(
result: .success([Account(id: "A1")])
))| Style | Benefit | Risk |
|---|---|---|
| Initializer | Explicit required dependencies; simple tests | More wiring in assembly code |
| Method parameter | Good for operation-specific dependency | Can clutter method signatures |
| Environment/service locator | Convenient for cross-cutting app environment | Hidden dependencies and harder tests |
| DI framework | Useful for very large dependency graphs | Registration magic and runtime failures |
Trade-offs
Don't create a protocol for every concrete class. Define one when it marks a genuine boundary, enables multiple implementations or provides meaningful testing isolation. DI is not a Singleton: a global instance might be convenient but introduces shared mutable state and test coupling. Be deliberate about object lifetime and actor isolation.
Personal study notes. Check details against your target Swift/iOS version and official documentation.