Migration from Monolith to Feature Module
eBay sponsored this post.
The eBay Android application is now a decade old and contains hundreds of features and activity classes in monolithic modules. With the growth of the app in recent years, the code became increasingly harder to maintain.
In order to scale — both in features and development — we had to further modularize our app into smaller feature modules. An app of this complexity could not afford to be rebuilt from scratch. Instead, we embarked on a journey to incrementally migrate from monolithic modules, while continuing to build new features and release the app on a regular cadence. In this post, we share our journey to modularize the app.
The Monolith Modules
The current state of the code reflects the evolving capabilities of the Android framework over time. As the ecosystem changed, the app was partially updated, although it still left behind unchanged code that made maintenance and scaling increasingly difficult.
For example, the old single Dalvik Executable (DEX) limit introduced the use of global static methods. The lack of a modern Jetpack library led to a complex custom data management framework. The evolving testing tools led to code with minimal tests, and the evolving development and build tools led to monolithic modules in the app.
The first version of eBay Android app modularization was nearly five years ago when the app was refactored into horizontally layered modules.
- The App module consisted of a presentation (UI) layer for all features (including shared UI classes).
- The Domain module consisted of a data layer for all features (including shared data classes).
- The Core/Infrastructure module consisted of all support code (including network, cache, logging and more).
As development continued, these monolith modules grew in size and complexity. Each module had a well-defined package structure, but classes crept across the package boundaries, resulting in an undesirably tight coupling of classes in a module.
The gradual growth of these monolithic modules also resulted in an equally steady increase in build times for our Continuous Integration and Continuous Delivery (CI/CD) jobs. There was no way to identify the separation of impacted features for a given code change. The entire test suite had to be executed even when something as small as a color change in one feature was merged.
As new features were added to the app, we saw an increase in download size. The introduction of Android app bundles helped with minimizing the download size by letting the Google Play store decide which resources a particular device needed. We took this one step further by identifying features that would only be used by a subset of consumers. These features will soon be deployed on-demand using modern application delivery mechanisms such as Android’s dynamic features, allowing new feature additions without increasing the app’s initial download size.
To scale in development and be able to deploy dynamic features, we first had to modularize our app into smaller feature modules.
The Modularized Application
Our vision for a modularized app is to have vertically sliced feature modules along with horizontal layers of shared support modules. At the top are the app module that contains the main application class and a Dagger component for application. Support modules at the bottom are commonly used classes that are shared across features. A feature module roughly maps to a feature for a particular domain. All feature modules are peers.
Our module structure does not use a centralized navigation module. Rather than use a central place for navigation, each feature declares the public interface in a separate API module. Peer features depend on this public API module to navigate to the feature. The feature implementation is in a nested module and is not accessible from other peer feature modules. This separation provides clarity on the navigation dependency and enables better build optimizations compared to using a centralized navigation module.
EBay’s native development staff is spread out over several domain teams who own different feature areas. We hoped to empower each of those teams to modularize their own code so the small mobile architecture team would not be expected to modularize the entire code base themselves.
First, we enabled the development of features in the new feature modules to stop the growth of existing feature modules. Most of the common shared classes that would be required by new features were still in monolith modules. One analysis of the scope of work to convert the entire shared codebase into smaller support modules showed that it would span multiple months. Instead, we decided to go with an incremental approach. We started top-down by first extracting common code from the larger app module to its own support module.
For example, BaseActivity, BaseFragments, Styles and Themes were pulled out of the app module to a new UI support module. This allowed developers to create new feature modules that only contained code for the presentation. These feature modules still depended on existing monolithic data modules for the data layer, but work on a new feature could now be in its module using the latest Android feature architecture. With this approach, we steadily slowed the growth of the monolith app module.
With domain teams now able to implement features in new modules, we worked our way down the stack. We then analyzed monolith data modules and began pulling out common data classes to their own shared data module. This step enabled feature modules to now include presentation, business and data classes all in the same feature module.
To avoid churn and support an easier code review process, most modularization would be carried out in multiple commits and often multiple pull requests. This process usually followed a pattern of untangle, move and modernize.
- Untangle: Break dependency across packages in monoliths — classes that reference each other across packages are updated to use a new interface instead. This isolation also enables better test support.
- Move: Move classes to a new package and module. Moving code to a new location might impact the many other places that reference the old code. This step can result in minor changes across many hundreds of files.
- Modernize: In this process, modernization means adopting the latest recommendations from the mobile architecture team, such as adopting Kotlin and using the latest support modules and components.
We simply repeated this process until there were no more common classes to move. The domain teams applied the same process to their own code to move feature code to new feature modules. Features in the monolith used common test classes that were now unavailable from feature test code. Along with extracting common code, the relevant test support code was also extracted to a new type of nested module called TestSupport. For example, shared UI modules also had a
uiTestSupport module that included test stubs, view matchers, test rules, etc. Feature modules now had test dependencies to this new test support module.
However, it is not always that simple. Modularization is more than just breaking dependencies and creating smaller modules. For feature modules to work correctly in parallel, feature modules must provide additional information.
- Resource collision: To avoid the collision of resource names between modules, each Feature Module had to provide a prefix used to namespace the resources safely.
- Consumer Proguard rules: Proguard rules are used by the Android compiler to post process code and shrink it by removing unused code. The old monolith modules lacked consumer Proguard definitions that were instead defined in blanket rules in the app module. Feature modules now define consumer Proguard rules themselves to give more control to developers of each module.
The modularization initiative has gone on for over a year at the time of this writing. Mobile architecture prioritized the ability for domain teams to modularize their own code. Over this time period, our teams have been able to modularize roughly 30% of the app. This modularization happened in parallel with new feature development, most of which has been in new modules. All of this work happened without disrupting our regular release cadence.
Code in the new module structure is easier to maintain, has well-defined boundaries and consistently has higher test coverage. Getting the code to this point opens up new opportunities to optimize our continuous integration pipeline. That is an area we will pursue next, and the plans are already quite promising.
We did, however, revisit one of our early decisions. Progress was slow in moving existing code to a new, separate feature module. We originally recommended that code moved out to new modules should also be rewritten in Koltin and be implemented using Google’s modern application development principles. Specifically, our recommendation had been that Java and Kotlin should not coexist in the same module in order to avoid double compilation. However, the domain teams did not always have time to tackle all of those tasks at once. So, the recommendation has been relaxed, thereby decoupling modularization from modernization. This has allowed teams to make progress in smaller steps. We are happy to report they are choosing to take the plunge and make progress.
Our work continues, but the end goal will not only increase developer confidence and code quality but will also allow for optimization in the build process. As a result, teams will be able to deploy features on-demand as eBay finds new ways to delight our customers.
Interested in pursuing a career at eBay? We are hiring! To see our current job openings, please visit: http://ebay.to/Careers
Feature image via Pixabay.