← Knowledge hub
Architecture · ORIGINAL STUDY NOTE

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.

Constructor injection at a use-case boundary
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.

Build a dependency graph deliberately
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.

Predictable stub with success and failure
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")])
))
Choose the injection style
StyleBenefitRisk
InitializerExplicit required dependencies; simple testsMore wiring in assembly code
Method parameterGood for operation-specific dependencyCan clutter method signatures
Environment/service locatorConvenient for cross-cutting app environmentHidden dependencies and harder tests
DI frameworkUseful for very large dependency graphsRegistration 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.

SOURCES & DOCUMENTATION

Personal study notes. Check details against your target Swift/iOS version and official documentation.