Introduction: Why Dependency Injection is a Necessity
In modern iOS and macOS development, code cleanliness and testability come to the forefront. One of the key principles for achieving these goals is Dependency Injection (DI). Without DI, classes often become tightly coupled, making them difficult to reuse, modify, and most importantly, to write unit tests for.
In practice, Dependency Injection in Swift means that an object receives its dependencies from the outside, rather than creating them itself. This may seem like a minor change, but it fundamentally alters the application's architecture. In this article, we will cover the main DI patterns in Swift: Constructor Injection, Property Injection, and using a DI container with Swinject as an example. Each method will be illustrated with practical examples for iOS/macOS.
Ready to make your code more flexible and testable? Let's begin.
1. Constructor Injection: The Most Reliable Method
Constructor Injection is the injection of dependencies through the class initializer (init). This method is considered the most preferable because it guarantees that the object is always in a valid state: all its dependencies are mandatory and immutable.
Let's look at an example. Suppose we have a class NetworkService that handles HTTP requests, and a class UserViewModel that uses this service.
// Protocol for abstractionprotocol NetworkServiceProtocol { func fetchUsers() async throws -> [User]}
// Real implementationclass RealNetworkService: NetworkServiceProtocol { func fetchUsers() async throws -> [User] { // Real HTTP request return [] }}
// ViewModel with Constructor Injectionclass UserViewModel { private let networkService: NetworkServiceProtocol
// Dependency is passed through the initializer init(networkService: NetworkServiceProtocol) { self.networkService = networkService }
func loadUsers() async { do { let users = try await networkService.fetchUsers() print("Loaded \\(users.count) users") } catch { print("Error: \\(error)") } }}
// Usagelet service = RealNetworkService()let viewModel = UserViewModel(networkService: service)Pros:
- The dependency is explicitly declared and mandatory.
- The object cannot be created without dependencies.
- Ideal for unit tests: easy to replace the real implementation with a Mock.
Cons:
- If there are many dependencies, the initializer becomes cumbersome.
- Not suitable for optional dependencies.
This method is the "gold standard" of DI. Use it wherever possible.
2. Property Injection: Flexibility for Optional Dependencies
Property Injection is the injection of dependencies through object properties. It is typically used when the dependency is not mandatory for the object to function, or when it cannot be passed through the initializer (e.g., in Storyboard/UIKit).
A classic example is a ViewController in iOS created from a Storyboard. You cannot modify its init, so you use a property.
protocol LoggerProtocol { func log(message: String)}
class ConsoleLogger: LoggerProtocol { func log(message: String) { print("LOG: \\(message)") }}
class MyViewController: UIViewController { // Optional dependency var logger: LoggerProtocol?
override func viewDidLoad() { super.viewDidLoad() logger?.log(message: "View did load") // If logger is not set, nothing happens }}
// Setup (e.g.