iOS Code Modular Architecture using Swinject & SPM
By default iOS Codebase is monolithic, Monolith is good when we are using good architecture like Clean Architecture + MVVM. Still, the app can become too big to be a monolith. And There is a need to make faster builds, reusable modules as frameworks, and isolated modules so people can work easily in separate cross-functional teams.
In this article, we will go through a sample app for Code modularization. You can download the full source code here: https://github.com/AnupKevins/iOSInitialModularArch
Modular Architecture
Modular Programming is a software design technique that emphasizes separating the functionality of a program into independent, interchangeable modules, such that each contains everything necessary to execute only one aspect of the desired functionality.
The sample project has three local modules and one 3rd party module:
- Common Module
- Debit Module
- Credit Module
- Swinject Module
Common module that handles the protocols and dependency injections.
Dependency injection is optional when we wanted to implement a modular architecture. But, the problem with not implementing dependency injection is that the modules will be highly dependent on each other, and changes in one module can affect other modules connected to it.
In the above diagram on the left, Without DI could create a lot of conflicts between other modules and makes it harder to maintain in bigger teams.
Whereas on the right diagram, With DI all of the modules will be connected through protocols, in which they would not interact directly with each other and makes the modules work more independently of each other.
Now let’s start the actual implementation of Modularization with DI:
- We will be using a third-party library https://github.com/Swinject/Swinject for Dependency Injection.
- And we will be using Swift Package Manager https://developer.apple.com/documentation/xcode/swift-packages
- Setup the sample project using Swinject:
- To setup we need to add package dependencies as shown below:
2. Create local Swift Package File → New → Package as shown below:
We need to create three local packages such as Common, Credit and Debit as shown in the downloaded sample project.
To add local common module we need to follow below steps:
- After creating a common package, save it in any location on the system.
- Now create a Modules folder inside the project.
- In the project directory, drag and drop the already saved common module as shown:
- Now again drag and drop the above Common package inside the code project as shown below:
- Similarly follow all the above steps to add all the other modules Credit and Debit.
- At the end we can verify whether the packages are added correctly by navigating to the link binary libraries.
After completing the setup for module, let’s start with the code implementation that will co-ordinate between modules.
CommonModule
We need to modify the common package with swinject dependencies as follows:
import PackageDescription
let package = Package(
name: "Common",
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "Common",
targets: ["Common"]),
],
dependencies: [
.package(url: "https://github.com/Swinject/Swinject.git", .exactItem(.init("2.9.1")!)),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "Common",
dependencies: [
"Swinject"
]),
.testTarget(
name: "CommonTests",
dependencies: ["Common"]),
]
)
All the source code of Common package will be added inside the sources folder and we will use mostly protocols as an interface to connect between modules as shown below:
import UIKit
import Swinject
public protocol Coordinating {
func navigateToDebitModule(baseVC: UIViewController?)
}
public protocol CreditFactory {
func makeCreditVC() -> UIViewController
}
public protocol DebitFactory {
func makeDebitVC() -> UIViewController
}
public class InjecterContainer {
public static var shared = Container()
}
The above code works as below:
- The Coordinating protocol will be an interface that will handle the navigation from one module to another.
- The CreditFactory and DebitFactory protocol will be an interface that will handle the creation of a UIViewController.
- The InjecterContainer is a singleton class that we would need to access the dependency injection globally for the module to access the code.
When using the official Swinject documentation, the first step is to register a service and its corresponding component with a container.
In our cases protocols like Coordinating, CreditFactory and DebitFactory are service and similarly we need to create a component pair Coordinator to register with the container.
import Foundation
import Common
import UIKit
class Coordinator: Coordinating {
var factory = InjecterContainer.shared.resolve(DebitFactory.self)
func navigateToDebitModule(baseVC: UIViewController?) {
guard let debitVC = factory?.makeDebitVC() else { return }
baseVC?.navigationController?.pushViewController(debitVC, animated: true)
}
}
To keep it simple, we didn’t keep the Coordinator inside the module or else we can keep it inside Common module.
In the above code, we will resolve the factory for debit, and conform the Coordinator class to the Coordinating protocol for dependency injection. Then we get an instance of a service(DebitFactory) from the container.
And the function navigates from credit module to debit module.
CreditModule
We need to modify the Credit package with Common dependencies as follows:
import PackageDescription
let package = Package(
name: "Credit",
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "Credit",
targets: ["Credit"]),
],
dependencies: [
.package(path: "../Common"),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "Credit",
dependencies: [
"Common",
]),
.testTarget(
name: "CreditTests",
dependencies: ["Credit"]),
]
)
For Credit module we will create a component pair for the container CreditModuleFactory which returns a CreditViewController as shown below:
import UIKit
import Common
public class CreditModuleFactory: CreditFactory {
public init() {}
public func makeCreditVC() -> UIViewController {
return CreditViewController()
}
}
class CreditViewController: UIViewController {
var coordinator = InjecterContainer.shared.resolve(Coordinating.self)
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
title = "Credit Module"
let button = UIButton()
button.backgroundColor = .red
button.translatesAutoresizingMaskIntoConstraints = false
button.setTitle("Go to Debit Module", for: .normal)
button.addTarget(self, action: #selector(didTapButton), for: .touchUpInside)
button.layer.cornerRadius = 20
view.addSubview(button)
NSLayoutConstraint.activate([
button.centerYAnchor.constraint(equalTo: view.centerYAnchor),
button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
button.heightAnchor.constraint(equalToConstant: 40)
])
}
@objc func didTapButton() {
coordinator?.navigateToDebitModule(baseVC: self)
}
}
In the code above, we will resolve a Coordinating and get the service instance which allowcus to do navigation directly on calling didTapButton and we created a simple UI to observe the screens from different module.
Note: We can’t run the app as we have not yet registered the services to a container in our application’s AppDelegate or scenedelegate
DebitModule
We need to modify the Debit package with Common dependencies as follows:
import PackageDescription
let package = Package(
name: "Debit",
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "Debit",
targets: ["Debit"]),
],
dependencies: [
.package(path: "../Common"),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "Debit",
dependencies: [
"Common",
]),
.testTarget(
name: "DebitTests",
dependencies: ["Debit"]),
]
)
For Debit module we will create a component pair for the container DebitModuleFactory which returns a DebitViewController as shown below:
import UIKit
import Common
public class DebitModuleFactory: DebitFactory {
public init() {}
public func makeDebitVC() -> UIViewController {
return DebitViewController()
}
}
class DebitViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.text = "Debit Module"
view.addSubview(label)
NSLayoutConstraint.activate([
label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
label.centerXAnchor.constraint(equalTo: view.centerXAnchor)
])
}
}
In the code above, we created a simple UI to observe the DebitScreen that we navigated from credit module.
Register the services to a container:
We can register either in AppDelegate or SceneDelegate, since we are using SceneDelegate we will register the dependencies as follows:
import UIKit
import Common
import Credit
import Debit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
guard let windowScene = (scene as? UIWindowScene) else { return }
registerDependencies()
setupRootViewController(windowScene)
}
func registerDependencies() {
let container = InjecterContainer.shared
// Coordinating - Service protocol
container.register(Coordinating.self) { _ in
Coordinator() // Component
}
container.register(CreditFactory.self) { _ in
CreditModuleFactory()
}
container.register(DebitFactory.self) { _ in
DebitModuleFactory()
}
}
private func setupRootViewController(_ windowScene: UIWindowScene) {
let window = UIWindow(windowScene: windowScene)
window.frame = UIScreen.main.bounds
self.window = window
let creditFactory = InjecterContainer.shared.resolve(CreditFactory.self)
guard let creditVC = creditFactory?.makeCreditVC() else { return }
let navigationController = UINavigationController()
window.rootViewController = navigationController
window.makeKeyAndVisible()
navigationController.setViewControllers([creditVC], animated: true)
}
}
In the above code:
- We will inject those dependencies in registerDependencies() such as the coordinating, and the factories (to create view controllers) through protocols with the functions of the Swinject.
- In setupRootViewController functions, we are setting up the root view controller for UINavigationController using resolve for CreditFactory service.
- After that the UIWindow creates the first screen (Credit screen) of CreditModule.
Conclusion:
- We are done with building a sample application using Swinject and SPM.
- Modular architecture with dependency injection is very efficient and scalable for iOS App development.