Why we decided to modularize
A significant portion (and a highly-valued cohort) of Wayfair’s customers opt into shopping with our native apps--that is, our iOS and Android apps. Our team — which once consisted of just a handful of Android and iOS developers — has grown eight-fold since 2016, and will continue to grow with Wayfair as shoppers continue to shift their purchases online.
The way we structured our code and our teams five years ago will no longer work as we continue to grow, and so we knew we needed to chart a path towards app modularization with two key goals in mind: improving ownership and reducing build times. We had gotten to a place where any change (even a trivial one) would take a minimum of three to four minutes to recompile and push to the device when doing local development. Also, as the team continued to grow and features shifted across different groups of people, it became very difficult to understand who owned what code in our monolithic codebase.
Just as micro-frontends have unlocked numerous benefits for web codebases (such as incremental upgrades, decoupled codebases, empowerment of autonomous teams), we think that our approach to modularization can unlock similar wins for app organizations. This is the story of how we unlocked an estimated 24k hours annually for our app teams.
How we began
Modularization of the iOS and Android apps at Wayfair went through several fits and starts, but we landed the effort successfully in 2020. Prior attempts left us with several different artifacts (like a module for resources shared across modules) which seemed like good ideas at the time but as we will get into a bit later, caused as much pain in the long run as they made things easier in the short term.
To tackle modularization, we came up with a set of layers that a module could live in which had specific purposes as well as a set of rules around how modules in the different layers could depend on each other. The layers were: the application layer (which contained only the app module), the feature layer (which contained all of the screens / flows of the application), a common layer for code that was shared across the features (such as shared models and helper code) and a platform layer that provided things like logging, tracking and networking. Our dependency rules allowed modules to depend on anything below them and anything in the same layer (except at the feature layer where we did not allow horizontal dependencies).
In general the modularization of our app went smoothly. By taking the approach of looking at one feature / screen at a time, we were able to quickly produce a good recipe for generating new feature modules without running the risk of our changes getting too large. Additionally we were successful in our attempts to improve incremental build times (especially when devs were able to work only in their feature modules). We used gradle build scans to help us here (more on this in the tooling section later).
A design pattern that helped with this was the use of “shims,” or interfaces that defined the destinations a feature navigated to (without creating hard dependencies between them). This unblocked us from modularizing dependent features as it gave us a uniform way to navigate (via abstract interfaces) to any feature (already modularized or not).
The shims allowed us to reduce the amount of inter-feature dependencies as well as set up a good pattern for navigation between fragments (it allowed screens to understand the concept of their destination pages without having to have direct knowledge).
Another very helpful strategy that we employed during our modularization effort was to enforce our dependency rules as part of the build. We created some gradle scripts that allowed us to inspect the module dependencies and enforce that only the types of relationships we wanted to allow were able to pass the build.
Our layered approach has served us well thus far, by defining clear layers with general guidance around what types of code should live in each layer we have allowed teams to move more independently while maintaining a level of coherence in our codebase. Additionally, by placing a number of generic utilities (like logging / networking / tracking / etc). We have reduced the initial investment of teams creating new apps at Wayfair because they no longer need to spend time creating these building blocks.
What did not work well
As alluded to earlier, one strategy that did not age well through our modularization efforts was the concept of a shared (Android) resources module. Since shared modules implied shared ownership, finding owners for issues that rose from modules like these was difficult. Additionally since these resources were not used directly in that particular module we needed to relax some of the lint rules that verified usage in that module which made it much more difficult to tell when they fell out of use.
The last and most important impact was in terms of build times. Since this shared module was near the root of our dependency tree (paired with how resource merging works) any changes in this module caused large portions of our app to need to be rebuilt. As we modularized more and more features, these shared modules continued to grow and their impacts were felt more and more. Since much of our focus on build times was on feature team build speed (making sure local development was mostly quick) this impact was largely overlooked for a while. These resources (and similar util) modules are enticing when beginning modularization because of the minimized amount of duplication but as we found in the long run the increased complexity and poor ownership they introduce outweigh the benefit (especially since these shared modules can lead to unexpected issues across the application because of the difficulty identifying their impacts).
Another common module that has led to issues for us was our “network models” module. We initially put all of our network model classes together in one module rather than co-locating them with the features they were used by. This has proven to be a very difficult knot to untangle. With some common concepts like Product we have ended up with massive classes which are a union of the use cases from a number of features. This (like the Android resources) made initial modularization quicker but has hidden a large amount of shared logic that can cause bugs across the app when the full impact of changes are not understood. This also has blocked our ability to attempt newer Android concepts like Instant Apps as this module alone introduces a huge amount of code to any module that depends on it.
Another key factor on our build times was our allowance of horizontal dependencies in the lower layers of our architecture. This meant that despite having only a few “layers” our true dependency tree was much deeper and interconnected than we had set out for it to be, which led to the large impact modules we talked about above as well as short circuiting some of the decoupled nature for features we were striving for.
Tooling and its benefits
One of the major things that allowed us to be successful in our modularization efforts were some of the tools that we used and developed along the way. One that was particularly helpful was a new module generator script, as it set things up in a consistent way for us (and set more sane defaults than the Android Studio wizard for our project). This initial module setup included some best practices like setting up strings.xml for all supported languages and setting up the default resource prefixes. The tools to show the dependency graph that was developed also allowed us to not only make sure regressions weren’t being introduced but also allowed us to look at the bigger picture as we were moving along. Gradle build scans were another tool that we used to validate that we were focusing in the right places as well, showing us places where build threads were waiting for their dependencies to finish. As you can see below we were able to more effectively use the build threads after our efforts.
Though our modularization journey was not perfect, we learned many things along the way and were ultimately successful against our main goals of improving build times and code ownership. By breaking up our features into well-defined feature modules it became much easier to tell who owned what. This has allowed us to continue to improve our issue triaging and allows teams to have a better understanding of the quality of the code they own. While we are not done we have made great progress and now have a much clearer understanding of the deeper dependencies we have uncovered along the way. We continue to identify areas (like the shared Android resources and network models) which will help unlock even more autonomy, stability and build time improvements.