Managing Navigation Between Multiple Modules in an iOS App Using Dependency Injection Containers
In modern iOS app development, breaking up an application’s functionality into different modules or screens is common. This modular approach allows for better separation of concerns and makes it easier to manage the complexity of large codebases.
However, navigating between modules in a way that maintains code maintainability and reduces coupling can be challenging.
One approach to managing module navigation is using the Dependency Injection design pattern.
Please take a look at my previous articles before continuing. Dependency injection fundamentals are described in the following articles:
Dependency Injection is a software design pattern that separates the creation of objects from their use. In the context of an iOS app, this means decoupling the navigation logic from the individual modules or screens.
By using Dependency Injection, we can ensure that the navigation logic is flexible and easy to modify without modifying the individual modules. The Navigation Controller or Coordinator can be responsible for maintaining the state of the navigation stack and passing dependencies to the destination view controllers.
To illustrate the benefits of using Dependency Injection for navigation, let’s consider an example scenario. Suppose we have an iOS app with three modules: a Login
module, a Basket
module, and a Payment
module.
The Login
module is responsible for authenticating the user, while the Basket
and Payment
modules allow the user to view and modify their basket and payment settings, respectively. We want to ensure the modules are decoupled from one another while also maintaining a clear and intuitive navigation flow for the user.
To achieve this, we can use a Navigation
service that is responsible for handling the navigation logic between the modules. The navigation service can be responsible for maintaining the state of the navigation stack and passing dependencies to the destination view controllers.
For example, when the user logs in, the Login
module can pass the user’s authentication token to the navigation service, which can then use it to initialize the Basket
and Payment
modules. The navigation service can also pass other dependencies, such as a network client or database object, to the modules as needed.
By using Dependency Injection for navigation, we can ensure that each module is responsible for its own functionality and does not have to worry about the details of how to navigate to other parts of the app. This reduces coupling and makes it easier to test and maintain the codebase.
Let’s Code
First, let’s define the modules of our app. For this example, we’ll create four modules: a LoginModule
, a BasketModule
, PaymentModule
, and a CoreModule
.
I’m only going to cover the critical code examples in this article, and you’ll be able to see the full codebase at the end.
The CoreModule
is responsible for the core features, such as DI Container tools, extensions, and utils.
typealias Factory = (DIContainerService) -> Any
protocol ServiceEntryProtocol: AnyObject{
var factory: Factory { get }
var instance: Any? { get set }
}
final public class ServiceEntry: ServiceEntryProtocol {
var instance: Any?
var factory: Factory
weak var container: DIContainer?
init(factory: @escaping Factory) {
self.factory = factory
}
}
public protocol DIContainerService {
func register<Service>(type: Service.Type, name: String?, factory: @escaping (DIContainerService) -> Service) -> ServiceEntry
func resolve<Service>(type: Service.Type, name: String?) -> Service?
}
extension DIContainerService {
@discardableResult
public func register<Service>(type: Service.Type, name: String? = nil, factory: @escaping (DIContainerService) -> Service) -> ServiceEntry {
register(type: type, name: name, factory: factory)
}
public func resolve<Service>(type: Service.Type, name: String? = nil) -> Service? {
resolve(type: type, name: name)
}
}
final public class DIContainer: DIContainerService {
private var services: [String: ServiceEntryProtocol] = [:]
public init() {}
@discardableResult
public func register<Service>(type: Service.Type, name: String?, factory: @escaping (DIContainerService) -> Service) -> ServiceEntry {
let entry = ServiceEntry(factory: factory)
entry.container = self
let key = "\(type)\(name ?? "")"
services[key] = entry
return entry
}
public func resolve<Service>(type: Service.Type, name: String?) -> Service? {
let key = "\(type)\(name ?? "")"
if let entry = services[key] {
var resolvedService: Service?
if let instance = entry.instance {
resolvedService = instance as? Service
} else {
resolvedService = entry.factory(self) as? Service
entry.instance = resolvedService
}
return resolvedService
}
return nil
}
}
We created a Dependency Injection Container named DIContainer
, which is located in the CoreModule
.
public protocol NavigationService: ExternalNavigationService {
var navigationController: UINavigationController { get set }
var container: DIContainerService { get set }
func popToRootViewController(animated: Bool)
}
public protocol PresentableView {
func toPresent() -> UIViewController
}
public extension PresentableView where Self: UIViewController {
func toPresent() -> UIViewController {
return self
}
}
public protocol PresentableLoginView: PresentableView {}
public protocol PresentableBasketView: PresentableView {}
public protocol PresentablePaymentView: PresentableView {}
public protocol ExternalNavigationService {
func openLoginViewController()
func openBasketViewController()
func openPaymentViewController()
}
And then, we created a bunch of protocols in the NavigationService
. These protocols will be used in the App Navigation.
Each module’s navigation service is defined by a protocol that specifies the methods for navigating the corresponding view controller. These protocols can be implemented by separate modules or by a single navigation service that handles the navigation between the modules.
import CoreModule
public protocol LoginNavigationService {
var container: DIContainerService { get set }
var navigationController: UINavigationController? { get set }
func openLoginViewController()
func openPhoneUpdateViewController()
func openBasketViewController()
}
public class LoginNavigation: LoginNavigationService {
public var container: CoreModule.DIContainerService
public var navigationController: UINavigationController?
public init(container: CoreModule.DIContainerService,
navigationController: UINavigationController? = nil) {
self.container = container
self.navigationController = navigationController
registerViewControllers()
}
private func registerViewControllers() {
container.register(type: PresentableLoginView.self) { container in
LoginViewController(navigationService: container.resolve(type: LoginNavigationService.self)!)
}
}
public func openLoginViewController() {
let viewController = container.resolve(type: PresentableLoginView.self)!
navigationController?.show(viewController.toPresent(), sender: nil)
}
public func openPhoneUpdateViewController() {
}
public func openBasketViewController() {
let appNavigationService = container.resolve(type: NavigationService.self)!
appNavigationService.openBasketViewController()
}
}
We created a class named LoginNavigation
. This class handles navigation from the login module to other modules.
Navigation classes have two dependencies. The first is a container that resolves instances we will use in the next step, and the second is a navigation controller that manages the presenting and pushing of view controllers
We will use the same implementation for the BasketNavigation
as well as the PaymentNavigation
. See the codebase.
Next, let’s define the AppNavigation
Service. The AppNavigation
Service manages the navigation stack, passes dependencies between modules, and opens specific view controllers.
import CoreModule
import BasketModule
import LoginModule
import PaymentModule
class AppNavigation: NavigationService, ExternalNavigationService {
var navigationController: UINavigationController
var container: DIContainerService
init(navigationController: UINavigationController, container: DIContainerService) {
self.navigationController = navigationController
self.container = container
}
func openLoginViewController() {
let loginNavigationService = container.resolve(type: LoginNavigationService.self)!
let viewController = loginNavigationService.container.resolve(type: PresentableLoginView.self)!
navigationController.show(viewController.toPresent(), sender: nil)
}
func openBasketViewController() {
let basketNavigationService = container.resolve(type: BasketNavigationService.self)!
let viewController = basketNavigationService.container.resolve(type: PresentableBasketView.self)!
navigationController.show(viewController.toPresent(), sender: nil)
}
func openPaymentViewController() {
let paymentNavigationService = container.resolve(type: PaymentNavigationService.self)!
let viewController = paymentNavigationService.container.resolve(type: PresentablePaymentView.self)!
navigationController.show(viewController.toPresent(), sender: nil)
}
func popToRootViewController(animated: Bool) {
navigationController.popToRootViewController(animated: animated)
}
}
We created an AppNavigation
class. As you see, App Navigation has an implementation similar to the above LoginNavigation
example. The point here is that AppNavigation
manages the navigation inter-module.
That’s all. You can see the full of code in my GitHub repository.
Conclusion
A Dependency Injection container can centralize the configuration and management of dependencies. This makes managing and modifying dependencies throughout the app easier while reducing coupling and increasing testability.
Overall, navigating between modules using Dependency Injection is a powerful way to maintain code maintainability and improve the flexibility of an iOS app. By separating the creation and use of objects, developers can create a more modular and extensible codebase that is easier to maintain and test.