A simple modular architecture with dependency injection in iOS

Kevin Abram
12 min readMay 20, 2023
Photo by NASA on Unsplash

Scalability usually comes into mind when we wanted to maintain a product long-term. As the product grows, there might be more features that we will add to the product, and more people might join the team.

That is when modular architecture and dependency injection comes in, in which the purpose is to make the product more scalable, and more maintainable while it scales with larger teams and more features.

In this Medium article, I will walk you through a simple project, the reasoning behind it, and the things that you can add to improve it further. You can download the full source code here: https://github.com/kevinabram111/Modular-Dependency

What is modular architecture?

In simple terms, modular architecture is a technique in which we modularize a codebase into several modules/features/screens that can interact with, but is independent of each other.

For example, an app can contain several screens such as:

  1. A login screen in which the user can log in to the app
  2. A register screen in which the user can register a new account
  3. A home screen that the user sees when the app is opened
  4. A profile screen that allows the user to edit his/her account

These screens can be defined as modules, and be separated from each other, carrying their functions.

What is dependency injection?

Dependency injection is a programming technique that makes a class independent of its dependencies, in which we will connect each class through interfaces or protocols that give some abstract implementation.

With this, it makes it safer to modify existing features without affecting other features, and the people working on a team can work more independently with each other.

Do we need dependency injection alongside modular architecture?

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, as shown in the image below:

In the image shown above on the left, the productivity of the people in a team can be hindered when we implement modularization (or modular architecture) without dependency injection. In bigger teams, this could create a lot of conflicts between other modules and makes it harder to maintain.

Alternatively, in the image shown above on the right, all of the modules will be connected through interfaces or protocols, in which they would not interact directly with each other, but with a defined set of protocols that makes the modules work more independently of each other.

How do we implement a simple dependency injection + modularization in iOS?

We will create a simple modular architecture with dependency injection that is easy to understand. The image below is the sample image of the modular architecture + dependency injection that we are going to implement, that we are going to connect through protocols and interfaces:

In the image above, notice that we are not going to connect the login and home directly, but they are connected indirectly through a set of protocols and interfaces.

We will be using a third-party library named Swinject for this project: https://github.com/Swinject/Swinject to help with the dependency injection that we will use.

To start with, you can download the complete project source code at: https://github.com/kevinabram111/Modular-Dependency to know deeper about the project.

The project will be consisting of only two screens, which are the Home screen and the Login screen. As shown in the image below:

As for the modular architecture, we will use Swift Packages to create our modules. You can refer to the official documentation here: https://developer.apple.com/documentation/xcode/swift-packages

How do we set up the project (App Delegates )

To set up the project, we will need to do two things, the first is to download the third-party library through SPM for Swinject: https://github.com/Swinject/Swinject. To do this, you can first navigate to the package dependencies on the project tab, and tap the plus button as shown in the image below:

Then, you can just copy and paste the git URL of the third-party dependency to the URL search bar, and add the package by clicking the button, as shown in the image below.

Then you can just follow the next steps, such as the version you want to use for the Swinject library.

For the local Swift Packages, we can create them through file -> new -> package, as shown in the image below.

You can then define the name and drag the package into the project into a folder that you like. I usually name it with a module at the end, for example, a Home feature would be named HomeModule.

Also, do not forget to add the packages, both locally and through SPM to the embedded content here, or it will not be recognized by the Xcode:

We will be using three local packages, which are:

  1. CommonModule: The module that handles the factory, protocols, and dependency injections
  2. LoginModule: The module that contains the first screen on the app
  3. HomeModule: The module that contains the second screen on the app

The other one will be our third-party library, which is Swinject which will help with our third-party dependency injection.

Note that when we wanted to create a new package, we will be putting our source code in the sources folder like in this CommonModule below:

How does the code work?

The code will work as the LoginModule interacts with the HomeModule through protocols mentioned in CommonModule as shown in the image below:

We can start by identifying the code in our Common Module, which is:

import UIKit
import Swinject

public protocol Routing {
func routeToHome(baseViewController: UIViewController?)
}

public protocol HomeFactory {
func makeHomeViewController() -> UIViewController
}

public protocol LoginFactory {
func makeLoginViewController() -> UIViewController
}

public class InjectionContainer {
public static var shared = Container()
}

In the code above, we will use mostly protocols as an interface to connect between modules. Note we will also use the factory pattern to create the view controllers. To explain more about what each code does:

  1. The Routing protocol will be an interface that will handle the routing, for now, we will make it simple and declare a basic generic routing
  2. The HomeFactory protocol will be an interface that will handle the creation of a home view controller that returns a generic UIViewController to connect through interfaces.
  3. The LoginFactory protocol will be an interface that will handle the creation of a login view controller that returns a generic UIViewController to connect through interfaces.
  4. The InjectionContainer is a simple singleton since we would need to access the dependency injection globally so that each Module would be able to access the code that is injected.

For the AppDelegate, we will make it like this:

import UIKit
import CommonModule
import HomeModule
import LoginModule

@main
class AppDelegate: UIResponder, UIApplicationDelegate {

var window: UIWindow?

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
registerDependencies()

let window = UIWindow(frame: UIScreen.main.bounds)
window.makeKeyAndVisible()
self.window = window

let loginFactory = InjectionContainer.shared.resolve(LoginFactory.self)

guard let loginViewController = loginFactory?.makeLoginViewController() else { return true }
window.rootViewController = UINavigationController(rootViewController: loginViewController)

return true
}

func registerDependencies() {
let container = InjectionContainer.shared
container.register(Routing.self) { _ in Router() }
container.register(LoginFactory.self) { _ in LoginModuleFactory() }
container.register(HomeFactory.self) { _ in HomeModuleFactory() }
}
}

In this code, we will do several things such as:

  1. We will inject those dependencies in registerDependencies() such as the routing, and the factories (to create view controllers as generics) through interfaces with the basic functions of the Swinject. You can refer to the basic functions in Swinject here: https://github.com/Swinject/Swinject
  2. Then we will resolve the dependencies when we need them, for example, we will resolve the factory for the Login page through this code: InjectionContainer.shared.resolve(LoginFactory.self)
  3. After that, since I create it programmatically in this project, I defined the UIWindow, the navigation controller, and create the first screen through a factory pattern, which is the Login page.

For some of you who are confused about dependency registration, we can take a look at Routing:

container.register(Routing.self) { _ in Router() }

In the code above, this means that we will need to register the Routing protocol, and a class named Router, in which the router class also conforms to the routing protocol. So the class file will be like this:

class Router: Routing {

func routeToHome(baseViewController: UIViewController?) {
// Some code for functionality here
}
}

Note that the Router class conforms to the routing protocol as shown in the code above, and we need to implement the functions defined on the protocol and add the necessary functionalities that we want to add.

Please note that when we wanted to create the project programmatically, you will need to delete files relating to SceneDelegates, which is the SceneDelegate swift file, and related code on the app delegate. We will also need to delete some values and only have some values on the info.plist file and into something like in the image below:

We will also include a router file that contains a class for routing that conforms to the routing protocol, for now, we will not be putting it into any module for simplicity, as shown below:

import UIKit
import CommonModule

class Router: Routing {

var factory = InjectionContainer.shared.resolve(HomeFactory.self)

func routeToHome(baseViewController: UIViewController?) {
guard let homeVC = factory?.makeHomeViewController() else { return }
baseViewController?.navigationController?.pushViewController(homeVC, animated: true)
}
}

In the code above, we will resolve the factory for home, and conform the Router class to the Routing protocol for dependency injection. This makes it possible for the class to be injected through CommonModule.

In the routeToHome function, it will create a home view controller through a factory, and push the view controller from the base as defined in baseViewController.

How does the code work on the HomeModule and LoginModule?

For the LoginModule, we will define it like this:

import UIKit
import CommonModule

public class LoginModuleFactory: LoginFactory {
public init() {}

public func makeLoginViewController() -> UIViewController {
return LoginViewController()
}
}

class LoginViewController: UIViewController {

var router = InjectionContainer.shared.resolve(Routing.self)

override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white

let button = UIButton()
button.backgroundColor = .blue
button.translatesAutoresizingMaskIntoConstraints = false
button.setTitle("Login", 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.widthAnchor.constraint(equalToConstant: 100),
button.heightAnchor.constraint(equalToConstant: 40)
])
}
}

extension LoginViewController {
@objc func didTapButton() {
router?.routeToHome(baseViewController: self)
}
}

In the code above, we will do several things:

  1. We will create a class named LoginModuleFactory that will serve as the class for the LoginFactory protocol for dependency injection. In this class, it will create the login view controller, and it is supplied through the protocol.
  2. We will resolve a routing from the dependency injection for us to do routing directly.
  3. We will create the login button and constraint it, here, I use a basic layout constraint that is already provided on the project on the UIKit.

Note that since we use dependency injection above, we would need to import the LoginModule directly, but we will just need to import the common module to the current module that serves as the protocol, which makes navigation a lot safer and cleaner.

As for the login page, it will show like this, and the login button will navigate to the home page:

As for the HomeModule, we will define it like this:

import UIKit
import CommonModule

public class HomeModuleFactory: HomeFactory {
public init() {}

public func makeHomeViewController() -> UIViewController {
return HomeViewController()
}
}

class HomeViewController: UIViewController {

override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .lightGray

let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.text = "Home"

view.addSubview(label)

NSLayoutConstraint.activate([
label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
label.centerXAnchor.constraint(equalTo: view.centerXAnchor)
])
}
}

In the code above, we will do several things:

  1. We will create a class named HomeModuleFactory that will serve as the class for the HomeFactory protocol for dependency injection. In this class, it will create the home view controller, and it is supplied through the protocol.
  2. We will create a simple label and constraint it, using the layout constraint provided by UIKit.

For the LoginModule, you can add some dependencies to the common module by modifying the package.swift file like this:

import PackageDescription

let package = Package(
name: "LoginModule",
products: [
.library(
name: "LoginModule",
targets: ["LoginModule"]),
],
dependencies: [
.package(path: "../CommonModule")
],
targets: [
.target(
name: "LoginModule",
dependencies: [ "CommonModule" ]),
.testTarget(
name: "LoginModuleTests",
dependencies: ["LoginModule"]),
]
)

For the home page, it will look like this, in which tapping the back button will redirect the user back to the login page:

For the HomeModule, you can add some dependencies by modifying the package.swift file like this:

import PackageDescription

let package = Package(
name: "HomeModule",
products: [
.library(
name: "HomeModule",
targets: ["HomeModule"]),
],
dependencies: [
.package(path: "../CommonModule")
],
targets: [
.target(
name: "HomeModule",
dependencies: [ "CommonModule"]),
.testTarget(
name: "HomeModuleTests",
dependencies: ["HomeModule"]),
]
)

And Voila, the project is done! 🎉

Where to take it from here?

From here, we can take your existing knowledge much further by introducing some packages that are not available locally but are shared throughout different repositories.

We can do this through git submodules, in which the overall picture will be like in the image below:

We can just create and clone a git repository, and put it inside our project, on a specific folder for example, such that the project will contain two git repositories like in the image shown below:

In the code inside of the utility module inside the folder name inside sources, we can add a file and write a code inside it, for example, a public extension to set a color from hex:

import UIKit

//MARK: - Color from Hex String
public extension UIColor {
convenience init(hex: String) {
var cString: String = hex.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()

if (cString.hasPrefix("#")) {
cString.remove(at: cString.startIndex)
}

if ((cString.count) != 6) {
self.init()
}

var rgbValue: UInt64 = 0
Scanner(string: cString).scanHexInt64(&rgbValue)

self.init(red: CGFloat((rgbValue & 0xFF0000) >> 16) / 255.0,
green: CGFloat((rgbValue & 0x00FF00) >> 8) / 255.0,
blue: CGFloat(rgbValue & 0x0000FF) / 255.0,
alpha: CGFloat(1.0))
}

convenience init(red: Int, green: Int, blue: Int) {
self.init(red: CGFloat(red) / 255.0, green: CGFloat(green) / 255.0, blue: CGFloat(blue) / 255.0, alpha: 1.0)
}

convenience init(hex: Int) {
self.init(red: ((hex >> 16) & 0xFF), green: ((hex >> 8) & 0xFF), blue: (hex & 0xFF))
}
}

Then, from that, after you embedded it, you can just import the UtilityModule to any file inside of a module like this:

import UtilityModule

class viewController: UIViewController {

func changeColor() {
view.backgroundColor = UIColor.init(hex: "FFFFFF")
}
}

From here, you can add some of the shared dependencies as well in each package. file, for example, you can add a utility module dependency like this in LoginModule:

import PackageDescription

let package = Package(
name: "LoginModule",
products: [
.library(
name: "LoginModule",
targets: ["LoginModule"]),
],
dependencies: [
// MARK: Local Dependencies
.package(path: "../CommonModule"),
// MARK: Shared Dependencies
.package(path: "../../Shared/UtilityModule")
],
targets: [
.target(
name: "LoginModule",
dependencies: [
"CommonModule", "UtilityModule"
]),
.testTarget(
name: "LoginModuleTests",
dependencies: ["LoginModule"]),
]
)

Other improvements that you might be interested in would be to include basic design patterns such as MVC, MVVM, and others in this project.

To conclude, a modular architecture with dependency injection is really helpful when we want to make a product that we can scale more safely and easily in bigger teams. I hope that this would be helpful, and have fun coding! 🙌

--

--