Android modularization

Guillermo Merino Jimenez
Making Tuenti
Published in
6 min readFeb 23, 2021

--

How we moved from a single module app to a feature-module based one

There are many posts and videos in the android community explaining the benefits of modularization, doesn’t matter if we choose a layer-based or a feature-based split, there are clear benefits in terms of isolation, testability, compilation improvements (from rebuilding almost the entire projects to just compile the affected module, for example in our case to run tests on a module it will take from 10 to 20 seconds), etc…

We want to explain our experience after a few years using modularization, our approach and the evolution of our initial design.

Why we started modularization?

We have an android application that is developed for several brands inside our company. Telefónica is deployed in several countries, but doesn’t have the same name (nor brand colors, neither features) in all of them. For example, in Brazil it’s called Vivo, in Spain, Movistar and in UK, O2.

From the beginning we wanted to reuse the same codebase and use Gradle flavors to customize the build for those different countries and brands. But the customization started to go deeper and some of those countries demanded the use of specific features that other ones didn’t want. And we didn’t want to include the resources (sometimes libraries) into the rest of the applications.

That’s where modules came to the rescue, we designed a first approach based on splitting the features into different modules that expose a (kind of) API: the use cases, with an enabled or disabled implementation. That let us provide dummy modules where the feature wasn’t required.

We split the application roughly like this:

Basic feature-based splitting

So, 3 layers: Commons, features and application.

Commons

Common modules are the ones that can be included in other feature modules. They provide implementations for statistic, the api client, asynchrony handlers, the typical common classes, etc…

Features

All the feature modules should handle all the logic related with the feature itself. This includes the UI if the feature has a designated screen, and also it should expose the use cases of the feature.

Example: Let’s say we have a feature called Contacts, that handles the UI related with the user phonebook and all the management of the contacts (search, synchronization with the server and the user’s device). It will provide use cases for other parts of the application, like for example, get contacts information

Application

It’s the upper layer of the app, it has dependencies on all the feature & common modules and orchestrates them.

Intercommunication between features

Imagine an application that retrieves the user phonebook and allows they to make calls. It will be splitted into two feature modules

  • Contacts
  • Calls

Contacts will have the use case described above

And phone module will have also a use case to make a call, given a msisdn

The call will happen in a screen that should display the number of the callee, but also the name and avatar in case is a known contact, so we can start implementing the model

And the use case will look like

Without modules we could directly change the signature of CallPhone#invoke(msisdn: String) to receive a Contact, but this will couple the Calls feature to our Contact model. What will happen if we need to modify the contact model to add more call-relative information? Other features will also receive it: Imagine that contacts are also used in a Chat feature, why do we need the list of msisdns there?

Then we could add a dependency from Calls to depend on Contacts, but this will easily become a mess as soon as we need anything from Call in Contacts module (let’s say msisdn formatting, for example): that will lead to a circular reference, which is not allowed in Dagger.

Feature modules should never depend on each other

Our solution is that a feature module should never have any dependency on other feature module, only app can depend on them:

Only app can depend on features

Whenever we need to access another feature, we use the adapter pattern. In our example we will create an adapter to map from the model used in Contacts to the one needed in Calls: we will expose an interface in Calls module that, given an msisdn, returns a model with the interesting data to make a call, this is, a CallableContact

And use it to complete the use case

Dagger to the rescue

Now we need to find a relation between Contacts model and our CallableContact, lucky of us, we use a dependency injector (Dagger 2)

And in a dagger module, we provide it

Summarizing, we use different modules per feature, that doesn’t depend on each other. App module is the only one that knows all the feature modules. They’re connected through adapters, that are interfaces implemented in app. And we use dagger to provide the implementations.

With this approach we can

  • Have the business logic use cases of the different features interact between each other.
  • Have separate models per feature
  • Benefit from all the module advantages.

Enabled/Disabled implementation

As I stated earlier, in our development scenario we need to provide applications to different countries that have features enabled or disabled. Also, we use to add extra features in internal-distribution apps that are not present in the release versions. The main approach for this is to have a config that, in Dagger, provides an enabled/disabled implementation of the feature’s interface. Having that, it’s easy to be able to enable it for debug or testing purposes whenever we want.

But sometimes an enabled implementation comes with extra luggage, it can be a heavy third-party SDK, a lot of media resources or some extra permission that we don’t want to add into the apps that have that feature disabled. And here is where modules come to the rescue again: being able to use gradle flavors to provide different implementations allows us to provide dummy solutions wherever we want to provide a disabled implementation:

Let’s say we want to extend our app that allows us to call and to search in the user agenda with a new feature: videocall, but that will be enabled only in certain countries (what we call brands, different product builds of the app)

We will start creating a videocall module and adding flavorDimensions to the build.gradle file

Adding folders with both implementations

We will need to add a folder under src for each of our flavors

enabled / disabled source folders

And there we can add the code for each one of the different implementations. Remember that you will need to use the same class name & method signatures in order to be able to compile the code with both implementations

Under enabled folder

Under disabled folder

And in main folder you can add the code that should be shared between both implementations, like for example the data models or the interfaces.

Adding dependencies based on the flavor

Finally, you can add any dependency to only the flavor you’re interested, for example, if our videocalls module needs to have a heavy third-party library, we can add it only to the enabled implementation:

Using the desired implementation

Finally, in our app’s build.gradle. Inside defaultConfig, we can add

And, wherever we want to use it

There is a must-read documentation on how missingDimensionStrategy works, which is not exactly trivial, here.

Summarizing, With this approach we can:

  • Have different code compiled & packaged depending on each implementation. Including third-party dependencies.

Special thanks to the rest of the Android Team:

--

--