As the team growth or the business model get more and more complex, we need to think of charting a path toward app modularization with two key goals in mind: improving the ownership and reducing the building times. With modularization, we could have numerous benefits, such as decoupled codebases, empowerment of autonomous teams, and incremental upgrades.
To tackle modularization, we decide to separate into a set of layers that a module could live in for specific purposes or rules around modules in different layers. Meanwhile, they could depend on each other.
Usually, an modularized app will contains following parts:
1. Core framework, which contains: design framework, networking framework, and persistence layer.
2. Common Layer, which shared library/models/utils across the features.
3. Feature layer, which builds on top of those core libraries, handles all the feature rendering and business logic.
4. Application layer, which contains only the app module.
The dependency rules here are allowing modules to depend on anything below them and anything in the same layer.
After establishing these guidelines, the transition to modularization should proceed smoothly. I'd like to emphasize that we can leverage Gradle scripts to create a scanning tool, which will assist us in monitoring code ownership and module dependencies throughout the transition.
Furthermore, by incorporating various generic utilities into the core framework, we can reap additional benefits. This strategic move will not only reduce the initial workload and investigation required when developing new apps but also enhance efficiency.
Some pain points while migrating to modularization
- The initial shared resource module lacks clear identification, and as shared modules expand in size, several challenges arise. It becomes increasingly difficult to ascertain ownership, and making updates to shared code can lead to regressions in other features. Additionally, verifying the continued usage of shared code becomes a challenging task.
- One of the most critical aspects to address in modifying shared code is the build time. These shared modules are situated near the root of the dependency trees, meaning that even a minor change to a shared module can trigger the need to rebuild a significant portion of the app.
- We encountered challenges with our "network models" module, which initially grouped all our network model classes together instead of associating them with the specific features they were used by. This decision has proven difficult to unravel. For instance, with shared concepts like "Product," we ended up with massive classes that amalgamated use cases from various features. While this initially expedited modularization, it concealed significant shared logic, leading to potential bugs throughout the app when changes were made without understanding their full impact. Moreover, it hindered our ability to adopt newer Android concepts like Instant Apps, as this module alone introduced substantial code dependencies to any module that relied on it.
- Another critical factor affecting our build times was our acceptance of horizontal dependencies within the lower layers of our architecture. Despite having only a few "layers" in theory, our actual dependency tree became much deeper and interconnected than initially intended. This not only resulted in the sizable impact of modules mentioned earlier but also compromised the desired decoupled nature of the features we aimed for.
Results from the modularization
While our modularization journey had its share of challenges, it was a valuable learning experience that ultimately achieved our primary goals of enhancing build times and clarifying code ownership. Breaking down our features into well-defined modules significantly improved our ability to determine who was responsible for what. This, in turn, has enabled us to continually refine our issue management processes and has given teams a better grasp of the quality of the code they oversee.
While our journey is ongoing, we've made substantial progress and gained a deeper understanding of the underlying dependencies we've encountered along the way. We remain vigilant in identifying areas for improvement, such as addressing shared Android resources and network models, with the aim of further enhancing autonomy, stability, and reducing build times.
Sample app of modularization
Today, let's make an app modularized, divide it into different modules, such as: data, UI, tests and dependencies.
1. Creating a Monolithic App:
- Start by adding a new project as an iOS App in Xcode.
- Develop a basic demo app that incorporates external dependencies through both Cocoapods and Swift Package Manager (SPM). This example encompasses various scenarios, including library integration and the inclusion of a build run script.
- For reference, a GitHub repository is available, providing the initial project's startup code.
2. Separating the Data Module:
- After establishing the monolithic app, the next step is to initiate modularization, commencing with the Data module.
- Add the Data module as a new framework project within the workspace.
- Within the Data module, house services, model classes, repositories, and database-related files.
- Relocate all files from the App's data directory to the Data module, ensuring accurate target memberships.
- Resolve errors in the main app by integrating the Data module as an external framework.
- Effect the requisite changes to guarantee public accessibility for classes, functions, and initializers.
- Regarding Cocoapods, update the podfile to encompass dependencies specific to the Data module.
- For SPM, introduce the necessary Swift Package dependencies to the Data module.
- If your project employs build/run scripts, ensure their incorporation into the Data module.
3. Separating the UI Module:
- Subsequently, pursue further modularization of the remaining UI component by instituting a new UI module.
- Migrate all UI-related files to this designated UI module.
- Append the UI module as a framework to the main app.
- Include UI-related dependencies (e.g., UIKit) in the UI module.
- Adjust the podfile to encompass dependencies particular to the UI module.
- Incorporate build/run scripts and confirm the public accessibility of all classes and views.
When running the app, be certain to designate the main app as the run target.
- To forestall bundle identifier conflicts, avoid embedding the Data module as an embedded framework in the main app if it is already employed as such in the UI module.
- Modularization furnishes a multitude of benefits, including enhanced code organization, superior testing options, expedited build times, and encapsulation. Nevertheless, it may introduce intricacy and augmented development costs for features necessitating alterations spanning multiple modules. Additionally, an abundance of dynamic frameworks could potentially impede app launch times.
As we add Data Module to the main app, and we also use data module's calss in the UI module. So, we have to add the data module in the UI module.
So, the main things is taht we are already using data module in UI module as embedded framework then no need to embed the Data module in the main app.
With judicious planning, modularization can significantly elevate code organization, testing capabilities, and build efficiency, thereby fostering a more maintainable and scalable iOS app.
1. Better for testing, we could have own unit test target and UI test target.
2. Speed up build time, only framework will be rebuilt at next compiling time.
3. Reuse framework in different projects.
1. More complex to understand to newbie.
2. May increase development cost as one requirement might involve multiple modules changes. For example, we could manage our frameworks in different repositories. The downside of this approach is that it makes us change more repositories, if a new feature requires changes to our framework layer, such as network layer.