Dependency Injection in Swift: Real-world Coding Examples

Transforming Your Codebase with Smart Dependency Injection Practices

Vikram Kumar
5 min readNov 26, 2023

Dependency Injection (DI) is a powerful design pattern that enhances the maintainability, testability, and flexibility of Swift code by managing the dependencies between components. In this article, we’ll explore the concepts of Dependency Injection and demonstrate how to implement it in real-world Swift applications through practical coding examples.

Photo by CDC on Unsplash

What is Dependency Injection?

Dependency Injection is a software design pattern that focuses on providing the dependencies of a component from the outside, rather than letting the component create its own dependencies. The primary goal is to decouple components and make the codebase more modular, scalable, and easy to test.

“Dependency Injection” is a 25-dollar term for a 5-cent concept. — James Shore

In Swift, dependencies typically include other classes, protocols, or services that a class relies on to perform its functionality. The Dependency Injection pattern involves injecting these dependencies into a class, either through the constructor (initializer) or via properties.

Why Use Dependency Injection?

  1. Testability: Dependency Injection makes it easier to write unit tests for your code. By injecting dependencies, you can easily substitute real implementations with mock objects during testing.
  2. Flexibility and Decoupling: Dependency Injection reduces tight coupling between components, making it easier to replace or upgrade dependencies without affecting the entire codebase.
  3. Readability and Maintainability: Code becomes more readable when dependencies are explicit and injected, leading to better maintainability and understanding of the code.
  4. Reusability: Components become more reusable as they can be easily reused in different contexts or projects.

Dependency Injection in Swift: Coding Examples

Example 1: Constructor Injection

// Protocol defining a data service
protocol DataService {
func fetchData() -> [String]
}

// Concrete implementation of the data service
class RemoteDataService: DataService {
func fetchData() -> [String] {
// Fetch data from a remote source
return ["Data1", "Data2", "Data3"]
}
}

// ViewModel for processing data
class DataViewModel {
private let dataService: DataService

init(dataService: DataService) {
self.dataService = dataService
}

func fetchData() -> [String] {
return dataService.fetchData()
}
}

// Usage
let remoteDataService = RemoteDataService()
let dataViewModel = DataViewModel(dataService: remoteDataService)
let data = dataViewModel.fetchData()
print("Fetched Data:", data)

In this example, DataViewModel is a ViewModel responsible for processing and transforming data. It depends on a DataService protocol, and the concrete implementation RemoteDataService is injected through the constructor.

Example 2: Property Injection with NetworkingService

// Protocol defining a networking service
protocol NetworkingService {
func fetchData(completion: @escaping ([String]?) -> Void)
}

// Concrete implementation of the networking service
class APINetworkingService: NetworkingService {
func fetchData(completion: @escaping ([String]?) -> Void) {
// Simulate fetching data from an API
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
completion(["Result1", "Result2", "Result3"])
}
}
}

// Class that relies on the networking service through property injection
class DataManager {
var networkingService: NetworkingService?

func fetchAndProcessData() {
networkingService?.fetchData { [weak self] data in
// Process the fetched data
print("Fetched Data:", data ?? "No data")
}
}
}

// Usage
let apiNetworkingService = APINetworkingService()
let dataManager = DataManager()
dataManager.networkingService = apiNetworkingService
dataManager.fetchAndProcessData()

In the Property Injection example, the DataManager class relies on a NetworkingService through a flexible property, enabling external components to inject different implementations, promoting code flexibility and testability.

DI Best Practices

Dependency Injection (DI) is a powerful design pattern, and following best practices can enhance the effectiveness and maintainability of your codebase. Here are some best practices for implementing Dependency Injection:

  1. Use Protocols: Define protocols for dependencies to allow flexibility and easy swapping of implementations. This promotes adherence to the Dependency Inversion Principle.
  2. Constructor Injection: Prefer constructor injection to provide dependencies explicitly. This makes dependencies clear and enforces that required dependencies are provided during initialization.
  3. Avoid Service Locator Pattern: Minimize the use of a service locator pattern, as it can lead to hidden dependencies and make the code harder to understand and maintain.
  4. Avoid Hard Dependencies: Minimize dependencies on concrete classes and aim to depend on abstractions (protocols) to facilitate testing and maintainability.
  5. Avoid Global State: Avoid using global state or singleton patterns for managing dependencies, as they can lead to hidden coupling and make the code less modular.
  6. Testability: DI should facilitate unit testing. Inject mock implementations of dependencies during testing to isolate the unit under test.
  7. Follow SOLID Principles: Adhere to SOLID principles, particularly the Dependency Inversion Principle, which encourages depending on abstractions and not on concretions.
  8. Avoid Dependency Cycles: Be mindful of dependency cycles, as they can lead to initialization issues and make the code more difficult to understand. Consider breaking cycles through abstraction or restructuring code.
  9. Document Dependencies: Document the dependencies of classes and components, making it clear what is required for proper functioning. This aids in understanding and maintaining the code.
  10. Use Factories for Complex Initialization: For complex object creation or initialization logic, consider using factories. This can centralize the creation of instances and handle any necessary setup.
  11. Review Code Reviews: Conduct code reviews to ensure that DI practices are consistently followed across the codebase. This helps maintain a clean and consistent approach to dependency injection.

By adhering to these best practices, you can create a codebase that is modular, testable, and easy to maintain, making the most of the benefits provided by the Dependency Injection pattern.

Most asked interview questions related to Dependency Injection

  1. What is Dependency Injection (DI) in Swift, and why is it important in software development?
  2. Explain the difference between Constructor Injection and Property Injection in Swift. When would you use one over the other?
  3. How does Dependency Injection contribute to the principles of SOLID in Swift programming?
  4. What are the benefits of using Dependency Injection in Swift, especially in terms of testing?
  5. Explain the role of protocols in Dependency Injection in Swift.

Conclusion

Dependency Injection is a crucial design pattern in Swift development, promoting code maintainability, testability, and flexibility. By understanding and applying Dependency Injection principles, developers can create modular, scalable, and easily testable code. The real-world coding examples provided in this article demonstrate how to implement Dependency Injection in Swift applications, enhancing the overall quality of the codebase.

Happy Coding!

--

--

Vikram Kumar

I am Vikram, a Senior iOS Developer at Matellio Inc. focused on writing clean and efficient code. Complex problem-solver with an analytical and driven mindset.