Against monolithic Android apps.

Sushobh Nadiger
7 min readJan 14, 2023

Imagine this.

You are working on an Android app, say an ecommerce app. You are asked to change the font of the product title in the product screen, which looks something like this….

This shouldn’t be a big deal right?

You checkout a new branch, make the changes and click run.

The gradle build takes quite some time. Upwards of 20 minutes.

After about 20 minutes, you are able to get the app running on your device.

Then you go through a series of onboarding procedures.

You must first enter your language preferences, then consent to location permissions, and then agree to a privacy agreement.

After all of this, you are directed to a login screen.

You paste the credentials that you had stashed away in the notes app into the input fields. When you click login, you’re hoping that the login API is operational and that your credentials haven’t been blocked.

Fortunately, you get logged in.

Once logged in, the app gives you a quick walkthrough of the app, which you click your way through before clicking on some random product.

Finally, you’ve reached the product screen. You exhale a sigh of relief as the font changes look good.

Wasn’t that a lot of labour to verify such a tiny change?

Something is wrong here and we have to fix it.

I’m sure there are many projects where you don’t have to go to such lengths to verify a minor change, but most of the projects I’ve worked on have had this issue. The amount of resources and time required to change the codebase so that each screen can be run and tested in isolation is the major impediment to resolving this situation.

Too many dependencies exist between different screens, owing to a bad habit of retaining global state.

Apart from global state, there are a few other factors which make running different screens in isolation difficult.

  1. Routing based on business logic, which will prevent a specific screen from opening because something else must be resolved before this screen can be opened. For example, on every screen, checking if the user is logged in and navigating to the login page if they are not.
  2. After logging in, shared preferences data is populated with authentication information, which is then used by all APIs. If you try to access a product screen without first logging in, all APIs will fail. This is the correct behavior, but there is no way to inject authentication information into the API calls from the outside. So the only way to get the product apis working is to log in first, which means you’ll have to log in every time you want to view a product screen.

In an ideal world, there would be no global state and the required authentication information would have been injected into the product screen from the outside. This would have allowed us to run and verify the product screen without having to login and onboard.

Fixing these issues will take a considerable amount of time, as will ensuring that nothing is broken after making changes.

Modularization of code is not enough, we need modular features.

It’s important to mention that the Android developer website didn’t even have a Modularization guide until recently; since then, a few guides have been added outlining best practises for modularization.

Even with extensive modularization, it is possible that we will end up with tightly coupled feature modules. The most common cause of this tight coupling is horizontal coupling in feature modules; that is, one feature module is dependent on another. Another major cause of tight coupling is global state, which typically manifests itself as variables stored in the Application class, making it difficult to run feature modules in isolation.

Speaking of modularization, I recently wrote a post on creating a stack of Android modules.

What qualifies as a modular feature?

First and foremost, can the feature be run and tested in isolation? If yes its on the right path. A more technical specification would consist of the following characteristics.

  1. Has a well defined entry point.

An entry point could be an Activity/Fragment, and its definition would include all of the parameters that it expects. Entry points are illustrated by the navigation graphs in the Navigation Component. Even if you do not want to use the Navigation Component library, you can still implement the Mediator Pattern to create well-defined entry points.

2. Has a well defined set of object dependencies which can be plugged in seamlessly.

Note that we are discussing object level dependencies here not library dependencies between feature modules.

If we are building the entire app, object dependency resolution will take place in the app module because that is where all of the feature modules are imported; however, if we are building our feature module in isolation, we can plug in fakes of the required dependencies.

It is important to bear in mind that, in contrast to Mocks, Fakes have actual implementation while taking some shortcuts here and there. Fakes are ideal for running feature modules in isolation because they provide just enough implementation to get the feature module running while avoiding the need to rely on actual production code.

3. Has incredibly short build times.

This is not a requirement, but rather a byproduct of creating modular features. We get significantly shorter build times because we only build the feature module and a temporary app module whenever we run the feature in isolation. In fact, there is no reason to build the entire app other than for deploying in my opinion.

In some ways, modular features are similar to Microservices in that they are run in isolation and can be deployed in isolation for rapid testing. The main difference is that, unlike Microservices, Modular features cannot be deployed in isolation for production because Android bundles the entire app as an APK and then deploys it.

Meanwhile, modular features have one significant architectural advantage over microservices: they do not require REST API calls or event streaming to communicate with other microservices; instead, they can call actual methods of other features.

Best practices.

  1. Always test features in isolation, as this will naturally enforce better codebase segregation. Avoid running the entire app, this is something that should be done rarely by the developers and mostly by the QA.
  2. Use Navigation component to travel between destinations or some other tool that abstracts away the process of switching between Android specific screens.
  3. Document the entry points and APIs exposed by the feature modules, and if possible, automate the process.
  4. Use Dependency injection between feature modules with the app module bringing everything together. Here is a post on this topic https://developer.android.com/training/dependency-injection/dagger-multi-module
  5. Provide extension points for common app lifecycle events, for example an extension point for the Application.onCreate() method. Feature modules can register their tasks with the App module that are to be run during this lifecycle event.
  6. Make sure to host dependency versions of common libraries in one file and pull the same file in all of the modules. This will prevent version conflicts and hard to track bugs. https://proandroiddev.com/better-dependencies-management-using-buildsrc-kotlin-dsl-eda31cdb81bf
  7. There is no need to require that all feature modules use the database or networking modules. To interact with the underlying platform, feature modules must be able to use a variety of techniques.

Summary

Over the last few years, I’ve had the pleasure of working on both monolithic apps and modular features, and if there’s one thing I’ve learned, it’s that monolithic apps are a nightmare to test, especially in the absence of unit tests. Meanwhile, even in the absence of unit tests, modular features provide significant benefits. The ability to focus on a single screen without having to navigate through tens of other screens is simply delightful.

Most apps do not neatly fit into either the monolithic or modular categories; instead, there is significant overlap. Some features can be tested independently with a little effort, while others are simply impossible. One thing is certain: testing features in isolation is always preferable to running the entire app and having to navigate through tens of unrelated screens while suffering from exorbitant build times.

Thanks for reading :)

--

--

Software Engineer working on Android , Node Js. I am also interested in Politics and Stocks.