By Ashish Pathak
Before you delve into how we modularized our driver app for Android, here’s a refresher on why we decided to do so 👇
How did we go about modularizing the driver app?
In a modularized application, the aim is to ensure proper segregation between the features with each feature fulfilling a specific purpose and is not bloated with unnecessary code. To achieve this, we also have a need for infrastructure code which is common across the features. This infrastructure code can be categorized into core and utilities groups. So, in general following 4 broad groups can be used as guiding principle:
- :core — Contains a set of core business related components.
- :utilities — Contains commonly used utilities which ease up development like common network code or common storage related code. The API specific details are still part of the features, not utilities.
- :features — Contains features that form the driver-app.
- :app — A standalone module which glues everything together.
Let us call these as groups. Only exception is the :app category which actually is a concrete module. It is an application gradle module. Any module that we create in the driver app should be part of one of these groups.
Group is a logical grouping of the modules. Think of a group as a namespace or the workspace or the logical grouping that contains other modules.
When to define a group?
As a group is a high level logical construct that contains modules or other groups, we should define a small set of groups at the root level. Any additional group at the root level must be discussed and approved with the entire team.
Apart from the group at the root layer, we define a group for a feature. This group contains the modules like :api, :shared-ui, :data and :implementation.
When not to define a group?
- At the root level there must be only the :app module and :core, :utilities and :features groups. No other group should be created at the root level.
If there is a need for a new group at the root level, we discuss that with the team before adding it.
- It is not recommended to create a group inside :core and :utility groups. :core and
:utilities groups should only contain a defined set of modules.
We also added automated checks to see that no new group is created at the root level and inside :core and :utility groups.
Represents a library. It could be a java library or an android library or an application module. Modules are a part of any one of the groups we defined above. The :app module is the only exception to this rule.
In the feature group, there are the following modules.
A java library module that contains an interface for the exposed apis.
It contains the shared UI elements. For e.g: components.
A multiplatfom library module that contains non-android classes such as use-cases. These use-cases should be written in the platform independent way to support KMM.
An android library module for the feature which contains the implementation details.
At the root there is the features group. This group has a set of features like say :featureA, the way it looks like in Android studio is as follows.
Fig: Shows feature structure with api, shared-ui and implementation modules
When to define a module?
We also created following guidelines for the team to follow in regards to groups and modules.
Defining a module depends on which group we are creating a module in. Following approaches are recommend:
- Inside features group: When we have to implement a user facing feature that is not closely related to existing features, create a new android library module which implements that feature. Call it :features:<feature-name>:implementation.
This android library module can only depend on other :api modules and not on any other android library implementation module except maybe :shared-ui module of that given feature.
For Horizontal Layering When we see the need to expose certain data or API from this feature to the outside world, we create a java library called :features:<feature-name>:api for any requirement that feature has from its dependent module. The other API or android library modules can only depend on such java library modules which expose APIs and not on the implementation details.
Create :features:<feature-name>:shared-ui module for sharing the Android specific UI logic from this feature to other features(eg: Component). Other features’ :implementation modules can depend on this to get that shared UI or functionality.
- Inside utilities group: While implementing any functionality which does not really belong to any specific feature and is common across features, like say custom view or RxJava operator etc, that can go inside the utilities group.
- Inside core group: Any shared business logic which is not user facing can be part of this group.
When not to define a module?
It is not recommended to create modules unnecessarily. This is to avoid bloating the source code with lots of unnecessary modules.
Here are some guidelines for when not to create a module:
- Modifying existing features which are already extracted out in its own modules.
- In this case since the change is in specific feature, just modify the module(s) for that feature
- There is a new custom view we are adding.
- Chances are we would use a custom view from aloha/asphalt. If not, and the view is generic enough, :utilities:views is a place where we can put it.
- There is a need for a base module which every module depends on.
- Get in touch with the code owners for the core or the utilities group to discuss this case.
- There is a need for certain core functionality.
- Get in touch with the code owners for the core or the utilities group to discuss this case.
We decided to layer our code horizontally with the common parts extracted in appropriate core and utility modules. Concept of horizontal layering is that each module as much as possible is self contained.
To add files, resources, or classes to the project, they should start in the implementation module of the feature. If the feature wants to expose them to the outside world, they should be moved to the api module. If they depend on the Android SDK, they should be moved to the shared-ui module. If they are data classes, they should be moved to the feature’s data module. If there are cyclic dependencies, they should be moved to the core or utility groups in the appropriate modules. To prevent poorly designed items from being added to core or utility, we wrote guardrails requiring review and whitelisting.
With all these guidelines and guardrails in place, we were all set to execute. We started extracting our first few modules. We quickly realized that there are certain tasks which are repetitive and boring. In Spite of that, those had to be executed with utmost accuracy. Also, when moving around some classes or packages, we saw a hell lot of errors being printed on the console which was overwhelming. But once we started addressing those one by one, we realized that some of those were really easy to solve.
After extracting out the first few modules, it was clear that for everyone on the team, this was boring to extract out modules. Here we came up with the idea of automating some of the repetitive steps. Automating some of those steps would mean more accuracy, reduced time for extraction and reduced cognitive load on the developer.
We started with writing simple grep and sed commands to automate some of the stuff like adjusting package names. Then finding out the names of resources being used in the given set of code. Finding out which classes are being used in the given set of code. While we were doing that, we realized that this is what we were doing most of the time when extracting feature:
- Move a package or a file to a new feature module.
- Find out which resources are being used by the code we have moved.
- Move those resources to the new module.
- Find out which dependencies are needed for the code we moved.
- Add those dependencies to the build.gradle
So, using the command we were writing earlier, we wrote a very small set of scripts which automated these tasks for us. With these scripts in place, we saw that it reduced the time to perform these tasks for several hours to just under 5 minutes. So, we started using these scripts when extracting out the modules. We initially had planned to extract out only 2 modules in 2 sprints but we were able to extract 14 modules with these scripts in 2 sprints worth of time with just 2 developers working in parallel. Also, because these scripts were handling the boring and repetitive tasks, the developers were free to concentrate on the refactoring that they were interested in which allowed us to reduce the code entanglement problem to a great degree.
- For Feature Module Layering we chose Horizontal Layering.
- To provide dependencies across the feature modules we expose dependencies through the API module of the feature.
- To share data classes across feature modules we are recommending to create data modules within each feature module. In cases where we see that circular dependency is created when sharing the data classes, we move those data classes in question to the :shared-models module.
- For KMM the suggested approach is to have a :cross-patform module between :api and :implementation modules
- For navigating between the features screen(across modules) jetpack navigation deeplinks are used.
- Subcomponents for feature modules are created to make it part of dagger’s main component which is in the :app module. For dynamic-features since the module dependency is inverted we need to use component dependencies approach
- For instrumentation tests in feature modules, each feature module needs to provide its own TestApplication to run individually or keep the instrumentation tests in the app module only.
- Gitlab’s CODEOWNERS can be leveraged to assign the owners to specific modules
Now that we know how we approached modularizing the Driver app, in the next blog we will talk about the benefits we got from doing so.
Check out the final blog in this series, here:
Curious to know how we do what we do? Check out more blogs from our vault. 📄