Android Modularization: The Story of Robinhood’s Android App

Jin Cao
Robinhood
Published in
10 min readMar 29, 2019

Some backstory

This is a story that is surprisingly common in the rapidly evolving tech world.

A developer builds an Android app. A small team forms around it. The team follows good practices and divides the project into a few Gradle modules to better organize the logic in the codebase. Great news, the app picks up steam and finds product/market fit. App’s dex count goes from 10k to 20k to 70k to 100k+, and now it has to use multidex. The team grows, and more features are added to the app. Before anyone even realizes it, the app module became a giant monolith of code that contains tightly coupled classes and features, when someone changes the styles of one page it could unintentionally change the styles of another completely unrelated page, and incremental build times skyrocket through the roof.

This is an illustrative story of Robinhood’s Android app. For the longest time, we had two main Gradle modules in our codebase: librobinhood and app. librobinhood was our library module that housed our model / API / persistence / caching layer, and app was everything else. This worked pretty well for us for a long time because it allowed for rapid feature development since everything was accessible by everything else. Also, perhaps contrary to popular belief, if the engineers follow some semblance of good practice and structure the code, the resulting codebase is mostly okay even if it’s in a giant monolithic module. However, we saw that our incremental build times steadily increased as we added features, and the previously clear code boundaries across features became blurred as features and UIs became more tightly coupled.

Case study: bottom navigation bar

Let’s use a more concrete example of how this can happen. In the Robinhood’s app user onboarding flow, we took inspiration from industry leaders (such as Google) and added a bottom navigation bar like you can see below.

Bottom bar in Robinhood’s user onboarding flow

Correspondingly, we defined a few styles and extracted a few layouts to help us out here, simplified and shown below.

include_onboarding_bottom_nav_bar.xml

Everything was fine and dandy, and we completed the native user onboarding experience in just a few weeks.

Fast forward one year, we wanted to build out the onboarding flow for options trading, and guess what, that flow also utilized the bottom navigation bar! Lucky us — since we already extracted out the navigation bar into a separate layout file, we would just use the same one that we made for user onboarding.

This was mostly fine, and some might even argue that it’s the correct move since we wanted a consistent style of bottom navigation bar throughout the app. The problem was that the original code for this wasn’t meant to be reused, and thus the author didn’t take the time to make it generalizable and customizable. Furthermore, this was very prone to breakage. Imagine that engineer A wrote an UI widget for feature, and then engineer B on the team used that widget to achieve similar results somewhere else. When engineer A’s feature changes slightly from design / product feedback, he or she could end up changing the original code while inadvertently breaking the behavior in the other spot (this is somewhat analogous to the perils of over-abstraction, except in our scenario we didn’t intend to share the code).

The road to modularization

Our very first commit to better modularize the app

After two and a half years since the Android app project started, we finally decided that it was time to better modularize the app. When we say modularize, we mean dividing up our giant Gradle modules — librobinhood and app — into smaller independent library/UI modules. There were several benefits for doing this.

  • Clear separation of concerns. As shown by the above case study, modular code allowed us to have strict encapsulation that’s specific to a particular feature, and developers have to make a conscious choice to expose or share a piece of code. It also forced us to clearly define inter-feature API boundaries. For example, how should we expose an Activity from one feature in a way that covers all use-cases? In general, independent feature modules allowed us to change something in one feature and have a guarantee that none of the other features were impacted in any way. As an added bonus, as we looked to expand our Android team, modularized codebase better enabled individual engineers to work independently without breaking everything and stepping on each others’ toes.
  • Build performance. Having more independent modules would allow Gradle to better parallelize tasks and cache outputs. For example, clean builds could be faster on a multi-core CPU since Gradle could compile each module on its own core, and incremental builds could be faster when only one module was changed since the build artifacts from other modules can be reused.**
  • Modularized app delivery. Are instant apps still a thing? Anyway, one of the original intentions was to be able to use frameworks like Android instant app, and a modular codebase is a key for that.

** this is only somewhat true if the app uses annotation processors because it triggers recompilation across most modules. However, kapt cache has gotten significantly more reliable and most popular annotation processors now take advantage of it.

Breaking out the librobinhood module

We started with our library module, and everything here was pretty straightforward. We broke out modules for our models, API definitions, analytics layer, shared utils, and a few other internal libraries that we’d developed. The bulk of the work involved moving classes around, breaking existing dependencies, creating new Dagger modules for each library that ties everything together, and ensuring that the app still compiled.

We got some wins right off the bat by going through this process. In the spirit of clear separation of concerns, we realized that our networking definitions were very coupled. For example, we had three separate definitions of OkHttpClient that were configured slightly differently, but they were used by over eight Retrofit interfaces. Even worse, it’s unclear which Retrofit interface should use which OkHttpClient. By breaking out some of the more independent parts of the library, such as lib-analytics, we were able to clearly define the functionalities and dependencies for the analytics API separately from the other networking logic.

Breaking out the app module

The app-module monolith was where things started to get hairy. We decided to go with the approach of dividing up the codebase around features and extract out feature modules. For example, the entire onboarding flow would live in its own feature module independent from other feature modules.

There were some big questions to answer before we could proceed. For example, how would we handle navigation between features when there were no explicit dependencies between them? Specifically, how would the onboarding flow start the banking flow if the onboarding feature module doesn’t even know that the banking feature module existed? We will talk about our navigation structure more in detail in another post (coming soon). At a high level, we extracted out a shared navigation module named lib-navigation which houses all of the shared schema definitions, such as the data type that was needed to launch the banking flow. The banking module will then provide a way to resolve that schema definition into an underlying Intent or Fragment and provide it via Dagger multibinding. Finally, the onboarding module can start that Intent or Fragment via the shared schema without having to know the underlying implementation details.

The other big concern that we had was around code reusability. Although we’d actually been pretty good at keeping the codebase clean and debt-free, there were still many cases where we’d reuse code that we shouldn’t have, such as styles, strings, and even not-so-generic View classes. We wanted to have the ability to reuse code when it made sense also but to decouple dependencies when necessary. For the first case, we decided to further extract out feature-lib modules, such as feature-lib-row-view, to house shared feature and UI code. For the second case, we decided to simply duplicate the code because they fundamentally represented different things but happened to share some UI similarities. Remember, we want to avoid the perils of over-abstraction so that it’s easier to change one feature without affecting another.

To summarize, here’s the overall strategy.

  1. Extract out a lib-common module that houses anything UI-related that can be shared, e.g. our BaseActivity / BaseFragment, generic strings, shared formatting code for dates / currency, etc.
  2. Create a lib-navigation module that handles cross-feature navigation.
  3. Choose a feature to extract, say the foo feature. Create module feature-foo, and move its corresponding classes / resources into the feature-foo while adhering to the following rules.
    i. No feature module should ever depend on another feature module.
    ii. Everything should live in the narrowest scope possible, e.g. no code should leak into lib-common unless absolutely necessary.
    iii. Any Activity / Fragment inside feature-foo that might need to be invoked from outside of the feature should be declared in lib-navigation.
    iv. For shared code, determine if the code should be shared. If so, define a feature-lib-bar for the shared component that the different feature modules can depend on. Otherwise, duplicate the code so that future changes to one won’t accidentally affect the other.
  4. Repeat step 3 until the app module is just a simple wrapper that consolidates all of the features together.
The goal that we wanted to get to via our modularization efforts

Case study: OptionDetailFragment

Detail screens in our app, such as the order detail and dividend detail, are styled similarly with shared functionality, like the ability to view the corresponding stock or cryptocurrency. We extracted this shared logic into a class called BaseDetailFragment.

Next, we started work on our Options product, and one of the screens we wanted to implement was the OptionDetailFragment which allows customers to see all of the data for a particular option, e.g. price, expiration, greeks. Because the overall look and feel of this screen is very similar to our existing detail fragments, we decided to just have OptionDetailFragment extend BaseDetailFragment.

One year later, we wanted to extract the feature-options module from app module. What do we do with OptionDetailFragment? Since it extends from BaseDetailFragment, which is used by other history detail screens, we would either need to extract it into a feature-lib-detail module or duplicate the code. But stepping back a bit, do these screens conceptually belong in the same group? No. They are only similar by circumstance, but it’s entirely possible that history detail screens will have a redesign that completely changes the look and functionality (which we would want to encode into BaseDetailFragment to avoid duplicating code) and that change should not propagate to OptionDetailFragment. In this scenario, it’s much better to just copy the relevant code from BaseDetailFragment into OptionDetailFragment and remove that unnecessary dependency.

We started our modularization strategy a year and a half ago, and the initiative got dropped and picked back up a few times due to other important projects. We are currently at 30+ modules, and it’s been super satisfying to strip out code from app and cut unnecessary dependencies. Figuring out how to house shared code & resources has definitely been the biggest challenge, and thinking of these problems beforehand and having a concrete plan to tackle them have made the migration significantly easier.

In terms of build speed, it’s not quite possible to isolate the impact of just the modularization effort because we’ve done so many other things to improve build speed, such as removing a lot of potentially unnecessary annotation processors from our app. Our incremental build times have plateaued, despite us continuing to build new features, which is a great sign. Finally, we took some inspiration from an Airbnb talk, and we realized that we can actually create custom build flavors for specific features that only compile that feature module since everything is decoupled. We will talk more in detail about this in an upcoming post about our navigation structure.

In terms of separation of concerns, it’s hard to quantify the real “wins” from this effort other than the fact that it just feels right. With a modularized codebase, engineers can feel much more comfortable making isolated changes and know exactly how the changes affected (or did not affect) different parts of the app. Decoupled features, even at the expense of duplicating some code, allow us to actually move faster because we can easily revamp features and refactor our codebase in a stepwise fashion.

P.S. our Android team (currently 22 people) is hiring devs across all experience levels! High growth & impact role, working on a lot of exciting projects. We use Kotlin, Rx2, Retrofit, Dagger2, Room, and other libraries. You can see our open jobs here.

--

--

Jin Cao
Robinhood

Engineering manager, Android at Robinhood, occasionally blogs about Android stuff. github.com/jinatonic