Modularization at Truecaller's iOS App
Daniel Amarante
Oct 24, 20245 min read
Modularization is a fundamental concept in iOS development that involves dividing a codebase into isolated modules, each with its boundaries and responsibilities. By implementing modularization correctly, developers can unlock benefits such as improved build times, stronger component boundaries, enhanced reusability, and easier code maintenance. In this article, we will explore the advantages and challenges of modularization, as well as delve into the approach taken by the Truecaller iOS app to achieve effective modularization.
What is a Module?
A module is an isolated piece of functionality, where a group of classes, protocols, and other components are separated from the rest of the codebase. A module has boundaries enforced by access, so each element can be hidden or exposed based on access modifiers. A module can be compiled and used independently.
Benefits of Modularization
Modularizing a codebase can bring many benefits, it can make a project more scalable and make the developer's life easier. Here are some benefits that can be achieved when applying modularization correctly.
Improved Build Time
When the codebase is split into modules, the Swift compiler can optimize build time in many ways. It can build unrelated modules in parallel and skip building unchanged dependencies. It also gives the developer the power to choose which modules to build manually, not having to build the whole codebase every time.
Stronger Boundaries Between Components
Modularization also enforces boundaries between components. It clarifies the dependencies between unrelated components and enforces the separation of concerns. All of these are good practices overall, but with modularization, there's a stronger boundary check to ensure these practices are followed.
Improved Reusability
With components split into modules, reusing them between multiple apps or between the main app and app extensions is easy, avoiding code duplication.
Fewer Merge Conflicts
Finally, with better boundaries, it tends to be less likely that people will be making changes to the same code at the same time. Depending on the tools used, this can also reduce the number of xcodeproj merge conflicts that we all dread.
Challenges of Applying Modularization
Even though modularization can lead to all these benefits, it can do more harm than good if not done carefully and not applied correctly. Here are some things to be aware of when modularizing.
Dependency Cycles
A dependency cycle occurs when two or more modules follow a path of dependencies that return them to their starting point. The simplest example is when two modules have direct dependencies on each other. When this happens, the app can no longer compile because the compiler does not know which module to build first.
Complex Dependency Graph
Even without cycles, a complex dependency graph can be a problem. It can significantly slow down the build time, making modularization pointless. That's because when making changes to a module, all modules with direct or indirect dependencies on this one will also have to be rebuilt.
High App Size or Startup Time
Modularization can also affect the application's size and startup time. This will depend on how the modules are built, and a compromise needs to be made when doing modularization.
Truecaller iOS App's Approach to Modularization
When rebuilding the iOS app with a modular approach, we tried to optimize for all these benefits while avoiding the drawbacks as much as possible. For that, we have used the following practices.
Splitting Interface and Implementation
We split each module into an interface and an implementation target to avoid dependency cycles and keep build time low.
The interface module is responsible for having all the public protocols and models that other modules will use. It should have close to no logic in it. It should be a very light dependency so that modules can depend on it without affecting their build time. Interfaces should not have any dependencies.
The implementation module is responsible for implementing the protocols declared in the interface module. It needs to depend on the interface to do so, and it can also depend on any other interface modules to use their functionality. No one is allowed to depend on other implementations.
Keeping Incremental Build Time Low
One of the goals of this architecture is to keep the incremental build time low. As most of the changes are done in implementation modules, and nobody depends on those, only one module needs to be rebuilt when making implementation changes. This allows for a high-speed development cycle when working on isolated modules since they can be built and tested independently.
Avoiding Dependency Cycles
The separation of modules between interface and implementation also helps avoid dependency cycles if we make strict rules regarding which modules can depend on which. If we enforce that interfaces should have no dependencies and that implementations can only depend on interfaces, the possibility of dependency between modules disappears.
Even if two modules depend on each other, in practice, only their implementations will depend on each other's interfaces. And these interfaces will not depend on anything else, so the dependency chain ends here. Cycles can still exist between specific entities inside a module, but this needs to be handled case by case.
Dependency Injection
For this solution, since classes cannot easily instantiate their dependencies due to the rules set in place, some dependency injection strategy needs to be applied. We went with a simple Pure Dependency Injection based on the initializer. To avoid repetition, we've used a code generation approach to generate all dependency constructors so that the developer of a module does not need to know or care about how its dependencies are constructed if they come from different modules. All dependencies are constructed from the app’s starting point and injected as needed. More on our approach to dependency injection should come in subsequent posts.
Test Doubles and Unit Tests
Unit tests are core to this architecture because they allow rapid development cycles when running only on a particular package. To simplify unit testing, more specifically, to simplify injecting test dependencies into implementations that are under test, we have expanded the interface/implementation module to include a mock module.
The mock module is similar to the implementation module, as it also implements the public interfaces, with the difference that it only implements test doubles to be used in tests for other modules.
This helps reduce the repetition of mock code while also allowing module developers to not care too much about implementing the dependencies they are using since they can simply import and inject the double-test version of it.
Conclusion
In conclusion, modularization in iOS development offers significant advantages for building scalable and maintainable applications. By splitting code into modules, developers can optimize build times, establish clear boundaries between components, and promote code reuse. However, addressing challenges like dependency cycles and complex dependency graphs is crucial. The Truecaller iOS app's approach to modularization, including splitting modules into interfaces and implementations, implementing dependency injection, and utilizing test doubles, is a successful example. By embracing modularization and adopting best practices, developers can create more efficient, adaptable, and robust iOS applications.
What’s Next
Looking ahead, we'll share deeper insights into our modularization journey.
Stay tuned for upcoming articles where we'll provide practical guidance on implementing modularization, including the tools and strategies we've employed.
Additionally, we'll delve into a comprehensive exploration of our custom dependency injection framework, offering an understanding of its inner workings and benefits.
Daniel Amarante
Oct 24, 20245 min read