Dependency Injection and Dependency Inversion
In Swift, dependency injection is commonly used to make objects more modular, testable, and reusable. Instead of creating dependencies inside an object, dependencies are injected into the object through initializers or methods. This allows for greater flexibility in changing and testing dependencies.
It’s the process in which we create object of class A into class B that depends on that object.
Obviously, the above code is going to work, but this violates the dependency injection principle. Moreover, this code is going to create tight coupling.
To avoid this, dependency injection says that dependency has to be injected and not created inside a class.
Types of dependency injection:
- Constructor injection
- Property injection
- Method injection
Constructor injection:
Here, the dependencies of a class are passed in through its constructor. In Swift, this means that the dependencies are declared as parameters of the class’s initializer.
class DataManager {
let networkService: NetworkService
init(networkService: NetworkService) {
self.networkService = networkService
}
func fetchData() {
// Use the network service to retrieve data
// ...
}
}
In this example, the DataManager
class takes in a NetworkService
instance through its initializer, and saves it to a property. The fetchData
method can then use the networkService
property to retrieve data.
Constructor injection allows the DataManager
class to be easily testable, since we can provide a mock NetworkService
instance in tests. It also makes the dependencies of the class explicit and easily discoverable, which can help with maintainability and understanding of the codebase.
Property Injection:
In property injection, the dependencies are passed to the object through properties. Unlike constructor injection, properties are set after the object is created, so they can be optional or have default values.
class MyViewController: UIViewController {
var myDependency: MyDependency?
override func viewDidLoad() {
super.viewDidLoad()
// Use myDependency
myDependency?.doSomething()
}
}
In this example, MyViewController
has a property myDependency
which is of type MyDependency
. The dependency is not required to be passed in the initializer, and it can be set at any time. When viewDidLoad()
is called, myDependency
is used to perform some action.
To use property injection, you need to make sure that the dependency property is optional and has a default value, or that it is explicitly set before being used. Also, it’s important to avoid using force unwrapping when accessing the property, as it can cause crashes if the property is not set.
Method Injection:
Method injection is another approach for dependency injection in Swift. In this approach, a method is defined with a parameter that represents the dependency. The dependency is passed to the method when it is called, which allows the method to use the dependency.
class MyViewController: UIViewController {
private let apiClient: APIClient
init(apiClient: APIClient) {
self.apiClient = apiClient
super.init(nibName: nil, bundle: nil)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func fetchData() {
apiClient.getData { result in
switch result {
case .success(let data):
// handle successful data retrieval
case .failure(let error):
// handle error
}
}
}
}
In the example above, the MyViewController
class has a private property apiClient
of type APIClient
. The init
method takes an apiClient
parameter and assigns it to the apiClient
property. This is an example of constructor injection.
The fetchData
method takes no parameters, but it uses the apiClient
property to retrieve data from the API. This is an example of method injection.
NOTE: In general, constructor injection is preferred over property and method injection in Swift, because it ensures that all required dependencies are available at the time of object creation and makes it clear what dependencies a class needs to function. With constructor injection, the dependencies are explicitly defined and easily visible in the class’s initializer.
Issues with Dependency Injection:
We will be able to access all the properties of the object that will be created. This will lead to the breakage of abstraction rule.
Solution:
- Make the methods that are not required outside the class as private. (But sometimes a developer may forget to initiate a method as private, which will loop us to the same issue)
2. Making use of protocols to pass the function or data required. (Dependency inversion principle)
Dependency Inversion:
Dependencies must always be passed using the highest level of abstraction i.e. protocols.
Dependency Inversion is a design principle that states that high-level modules should not depend on low-level modules, but both should depend on abstractions. This is achieved through the use of abstractions or protocols to define the required functionality and decouple the high-level and low-level modules.
Let’s take an example to understand this better. Suppose we have an application that requires the functionality to send email notifications. We have a high-level module NotificationService
that is responsible for sending notifications, and a low-level module EmailSender
that is responsible for sending emails. In a non-inverted dependency scenario, NotificationService
directly depends on EmailSender
.
class NotificationService {
let emailSender = EmailSender()
...
}
In this case, if we need to change the email sending functionality or add new functionality, we would have to modify NotificationService
, which is not ideal. Instead, we can invert the dependency and define an abstraction or protocol EmailSending
that defines the required functionality. The EmailSender
class can then implement this protocol, and NotificationService
can depend on the protocol abstraction rather than the concrete EmailSender
class.
protocol EmailSending {
func sendEmail(to: String, subject: String, body: String)
}
class EmailSender: EmailSending {
func sendEmail(to: String, subject: String, body: String) {
// send email using SMTP or API
}
}
class NotificationService {
let emailSender: EmailSending
init(emailSender: EmailSending) {
self.emailSender = emailSender
}
...
}
In this way, NotificationService
is decoupled from the concrete EmailSender
class and depends on the protocol abstraction instead. We can now easily swap out the EmailSender
class with a different email sending class that also implements the EmailSending
protocol without having to modify NotificationService
.
Dependency Inversion helps to achieve more flexible and maintainable code by reducing the coupling between modules and promoting the use of abstractions to define the required functionality.