Mastering Dependency Injection in Swift: Best Practices and Common Pitfalls

Adrian Borlido
6 min readMar 13, 2023

Working with Swift can sometimes feel like trying to wrangle a herd of cats. But fear not! Dependency Injection is a great tool to help bring order to the chaos of your code.

In this article, we will explore Dependency Injection in Swift, including its benefits, implementation techniques, and potential issues.

What is Dependency Injection?

Dependency Injection is a programming pattern where components are designed to be independent of each other by inverting their dependencies. It is a design pattern that allows the components to interact with each other through interfaces instead of concrete implementations.

Benefits of Dependency Injection

Dependency Injection promotes code flexibility, maintainability, and testability. By using DI, you can create more modular and extensible code that is easier to maintain and test.

Some benefits are:

Flexibility: DI allows you to easily swap out dependencies without changing the code that uses them. This makes it easy to adapt your code to changing requirements and use cases.

Testability: By separating dependencies from the code that uses them, you can more easily test your code in isolation. Since the dependencies are separated from the component, it can be replaced with a mock object during testing. This allows the component to be tested in isolation, making the testing process easier and more reliable.

Modularity: DI encourages modularity by breaking down your code into smaller, more manageable components. Making your code easier to read, understand, and maintain.

Maintainability: Since the components are decoupled, changes made to one component will not affect other components. This makes it easier to make changes to the program without introducing bugs or breaking existing code.

Reusability: DI promotes code reusability. Since the components are designed to be independent of each other, they can be reused in other parts of the program or even in other programs.

Implementation Techniques

In Swift, there are several techniques for implementing Dependency Injection. Each technique has its own strengths and weaknesses, and the choice of which technique to use will depend on the specific needs of your program.

Constructor Injection

Constructor Injection is a technique where dependencies are injected into a class through its initializer. This technique involves creating a designated initializer that accepts the dependencies as parameters.

protocol UserService {
func getUser(id: Int) -> User
}

class UserController {
let userService: UserService

init(userService: UserService) {
self.userService = userService
}

func showUser(id: Int) {
let user = userService.getUser(id: id)
// display user information
}
}

In the example above, the UserController class depends on the UserService protocol. The UserService is injected into the UserController through its initializer.

Property Injection

Property Injection is a technique where dependencies are injected into a class through its properties. This technique involves creating a property for each dependency that needs to be injected, and setting them from outside the class.

protocol ProductService {
func getProduct(id: Int) -> Product
}

class ProductController {
var productService: ProductService!

func showProduct(id: Int) {
let product = productService.getProduct(id: id)
// display product information
}
}

In the example above, the ProductController class depends on the ProductService protocol. The ProductService is injected into the ProductController through its productService property. Note that the property is declared as an implicitly unwrapped optional, which means that it must be set before it is used. This technique can be useful when the dependency is not required during initialization, but can be set at a later time.

Method Injection

Method Injection is a technique where dependencies are injected into a class through its methods. This technique involves creating methods that accept the dependencies as parameters.

protocol PaymentService {
func makePayment(amount: Double)
}

class CheckoutController {
func checkout(paymentService: PaymentService, amount: Double) {
paymentService.makePayment(amount: amount)
}
}

In the example above, the CheckoutController class depends on the PaymentService protocol. The PaymentService is injected into the CheckoutController through its checkout method. This makes it easy to swap out the PaymentService with a different implementation.

Dependency Injection Containers

Dependency Injection Containers are a technique for managing dependencies in a centralized location. The container is responsible for creating and managing the dependencies, and provides a way for the rest of the code to access them.

Here is an example of how to implement a Container using the popular framework, Swinject:

let container = Container()

container.register(UserService.self) { _ in
UserServiceImpl()
}
container.register(UserController.self) { r in
UserController(userService: r.resolve(UserService.self)!)
}

let userController = container.resolve(UserController.self)!

In the example above, the container registers the UserService and UserControllerclasses, along with their dependencies. The container then resolves the dependencies and creates instances of the classes as needed.

Potential Issues

While Dependency Injection can be a powerful tool for improving code quality, it is not without its potential issues.

Some of the most common issues to watch out for are:

Dependency Cycles

Dependency cycles occur when two or more classes depend on each other, creating a circular reference. This can lead to difficult-to-debug issues and make it difficult to maintain the code.

To avoid this, it’s important to use the Dependency Inversion Principle (DIP) which suggests depending on abstractions instead of concrete classes to break the cycle. Using lazy initialization or property injection can also help to avoid circular references between classes, but can make the code more complex.

It’s essential to carefully design the relationships between classes to minimize the chances of creating circular dependencies. This can be achieved by using the Single Responsibility Principle (SRP) which suggests that a class should have only one reason to change. It’s also possible to use design patterns like the Factory Pattern, which can help manage dependencies and avoid circular references.

It’s crucial to detect Dependency Cycles early in the development process and prevent them from occurring. Using automated code analysis tools like SwiftLint and Xcode’s built-in static analyzer can help detect circular dependencies in the codebase.

Overuse of Dependency Injection

Overusing of Dependency Injection can result in numerous dependencies being injected into a class. This can make it harder to understand the purpose of the class and the interactions between its dependencies. When this happens, it can become difficult to make changes to the class or its dependencies without causing unintended consequences.

For example, a class that has many dependencies injected into it may require a complex initialization process, which can be difficult to manage and understand.

To avoid these issues, it’s critical to use DI judiciously and only when it makes sense. When deciding whether to use it, consider the complexity of the class and its dependencies, as well as the potential for changes to those dependencies in the future.

If you find that a class has too many dependencies, consider breaking it up into smaller, more manageable classes. This can help to reduce the complexity of the code and make it easier to manage dependencies.

Difficulty with Third-Party Libraries

Using Dependency Injection in Swift can be challenging when working with third-party libraries that do not support it. Closed-source or proprietary libraries can make it harder to substitute your own implementation of a class, which may be necessary for testing or to meet the needs of your application.

Some third-party libraries may use their own mechanisms for DI, which can conflict with the framework you are using. This can result in a situation where the same dependency is being managed by multiple frameworks, leading to confusion and potential bugs.

To mitigate these issues, it’s essential to carefully choose third-party libraries that are compatible with your DI framework. If you encounter a library that is not compatible, you can use strategies such as wrapping the library in a layer or using the Adapter Pattern.

Wrapping the library in a layer that supports DI involves creating a class that acts as a bridge between the library and your application code, allowing you to manage the library’s dependencies using your chosen framework. The Adapter Pattern involves creating an adapter class that adapts the third-party library to your DI framework.

Conclusion

Dependency Injection is a powerful tool for improving code quality in Swift. By using DI, you can create more modular, testable, and maintainable code.

In this article, we explored the benefits of DI, as well as several techniques for implementing it in Swift. We also discussed some potential issues to watch out for, such as dependency cycles, overuse of Dependency Injection, and difficulties with third-party libraries.

Overall, Dependency Injection is a valuable technique that every developer should have in their toolkit. By using it wisely, you can create code that is easier to maintain, test, and extend, and make your life as an iOS developer just a little bit easier.

Happy Coding!

--

--

Adrian Borlido

I'm an iOS developer with a passion for creating cool stuff. I'm fluent in Swift and Objective-C, and I love to experiment with new technologies.