Dependency Injection (DI) is a powerful design pattern that increases code quality and enhances modularity, reusability, and testability in software development. In this blog post, I’ll focus on three main types of Dependency Injection: Constructor Injection, Property Injection, and Method Injection. I will break down each type with easy-to-understand examples showing how to use each of them.
On a side note, it is very important to understand the “programming to an interface, not an implementation” concept which explains why injecting a protocol instead of a concrete implementation offers advantages, primarily centered around code flexibility and testability, loose coupling, reusability, and easier maintenance. I will dive deeper into these concepts in different blog posts.
Back to the three types of DI:
Constructor Injection
Constructor Injection is a form of DI where dependencies are supplied to an object through its constructor, or initializer in Swift. This ensures that an object has all the required dependencies before it is used, promoting robust and easily testable code. It is generally the most recommended type of dependency injection.
protocol Storage {
func save(data: String)
}
final class Store {
let storage: Storage
// 1. Injecting the object conforming to the Storage protocol
init(storage: Storage) {
self.storage = storage
}
// 2. Using the object conforming to the Storage protocol
func save(message: String) {
storage.save(data: message)
}
}Usage example:
// Assuming `LocalStorage` conforms to Storage
let store = Store(storage: LocalStorage())
store.save(message: "Hello, World!")Let’s also create a unit test (p.s. I am a fan of TDD) for the Store class that uses Constructor Injection for its Storage dependency. We’ll also create a mock object that conforms to the Storage protocol to simulate the behavior we expect during testing.
class StoreTests: XCTestCase {
func testSave_givenMessage_whenSaved_thenMessageIsStored() {
// Arrange
let storageMock = StorageMock()
// Here DI benefit is very clear: mock instead of concrete implementation can be injected
let store = Store(storage: storageMock)
let message = "Hello, World!"
// Act
store.save(message: message)
// Assert
XCTAssertEqual(mockStorage.savedData, message)
}
}
// Mock object to simulate expected behaviour
struct StorageMock: Storage {
var savedData: String?
func save(data: String) {
savedData = data
}
}Property Injection
Property Injection is a form of DI where dependencies are set directly on object properties after the object has been initialized. Unlike Constructor Injection, Property Injection allows the object to exist in a partially initialized state, making it suitable for situations where not all dependencies are essential for the object’s basic functionality. It’s often used in frameworks like UIKit, where objects are frequently initialized without a custom constructor.
protocol Storage {
func save(data: String)
}
final class Store {
// 1. Declare the property as optional
var storage: Storage?
// 2. No need for a custom initializer in this case
// 3. Using the injected object conforming to the Storage protocol
func save(message: String) {
// Make sure storage is set before trying to use it
guard let storage else {
// Handle error and exit early
return
}
storage.save(data: message)
}
}Usage example:
let store = Store()
// Assuming `LocalStorage` conforms to Storage
store.storage = LocalStorage()
store.save(message: "Hello, World!")Here is XCTest to test the Store class, which utilizes property injection for its Storage dependency. We’ll use the same storage mock from the Constructor injection example above to simulate the behavior.
class StoreTests: XCTestCase {
func testSave_givenStorageSet_whenMessageSaved_thenMessageIsStored() {
// Arrange
let storageMock = StorageMock()
let store = Store()
store.storage = storageMock // Property injection
let message = "Hello, World!"
// Act
store.save(message: message)
// Assert
XCTAssertEqual(mockStorage.savedData, message)
}
func testSave_givenStorageIsNotSet_whenMessageSaved_thenMessageIsNotStored() {
// Arrange
let store = Store()
// store.storage = storageMock - let's not inject the property
let message = "Hello, World!"
// Act
store.save(message: message)
// Assert
// No data should be saved if the storage is nil.
XCTAssertNil(store.storage)
}
}Method Injection
Method Injection is a technique where dependencies are provided to an object through a method rather than via the constructor or properties. This approach is particularly useful when you need to pass a dependency temporarily for a specific operation, or when you want to change the object’s behavior dynamically. In this manner, the dependency is provided at the time the save operation is carried out, making the object’s behavior flexible and context-sensitive.
protocol Storage {
func save(data: String)
}
final class Store {
// 1. No longer hold a permanent reference to the storage object
// 2. No need for a custom initializer in this case
// 3. Inject the dependency through a method
func save(message: String, using storage: Storage) {
storage.save(data: message)
}
}Usage example:
let store = Store()
let localStorage = LocalStorage() // Assuming LocalStorage conforms to Storage
store.save(message: "Hello, World!", using: localStorage)Here is XCTest to test the Store class, which utilizes method injection. We’ll use the same storage mock from the previous examples above to simulate the behavior.
class StoreTests: XCTestCase {
func testSave_givenStorage_whenMessageSaved_thenMessageIsStored() {
// Arrange
let storageMock = StorageMock()
let store = Store()
let message = "Hello, World!"
// Act
store.save(message: message, using: storageMock)
// Assert
XCTAssertEqual(storageMock.savedData, message)
}
}In this unit test, we arrange the setup by creating a MockStorage object and a Store object. We then act by calling the save(message:using:) method on the Store object, passing in the MockStorage object. Finally, we assert to check that the data was saved correctly in the MockStorage object.
Conclusion
Dependency injection is a powerful programming technique that offers a myriad of benefits, including improved modularity, ease of testing, and greater control over object behavior. You’ve just explored three main types of DI—Constructor Injection, Property Injection, and Method Injection—each serving different use cases and offering varying degrees of flexibility.
A common thread across all three types is the ease with which they facilitate unit testing. By injecting dependencies, we can easily swap real implementations with mock objects, making it easier to isolate the unit of work being tested. This isolation ensures that tests run quickly and that failures are easy to diagnose, contributing to a more robust, maintainable codebase.
In essence, DI isn’t just about writing clean code; it’s about writing code that is testable, maintainable, and flexible. This is especially crucial in larger projects where the cost of neglecting tests can be very high. So, as you architect your apps and systems, consider leveraging one of these injection methods. Your future self, your teammates, and your stakeholders will thank you for the quality assurance and flexibility it provides.
Comments are closed.