Modularization for a smooth scaling at OLX

Senior Android Engineer unveils why they chose modularization, the process and key learnings.

Unlocking tech talent stories

December 6, 2021

Growing Android projects can reach a certain scale when working with a single module starts to become problematic. Building speed is gets slower, boundaries between features start to be blurry, merging conflicts are a daily nightmare, tests are too slow to run in the CI, to name just some of the problems. To overcome these hurdles and enable smooth scaling, one solution to consider is Modularization. At OLX, we went through this journey, and in this article, I wanted to share why we went with modularization, the outcome, and the learnings of this journey.

Why opt for Modularization?

Build time

With a single module project, a change in the code will result in re-compiling the whole app. Modularization solves this by compiling only the affected module(s), this is a time-saver for a day to day development.

Parallel development

When the number of contributors increases for a project, the number of conflicts increases at touchpoints. This leads to an increase in the effort and time to solve those conflicts. By modularizing the app, we isolate the features and thus minimize the number of conflicts.

Side effects

The monolithic approach tends to have a higher code coupling and poor separation of features. This usually leads to surprise side effects that are difficult to spot earlier. By isolating features into modules, we lower interference by other features changes and avoid unseen production bugs.

Clear ownership

Usually, big projects with big teams have feature-based ownership. With a monolithic project, it’s very difficult to set the boundaries between features and team ownership. But modularization makes it simpler, clearer, more visible, and easy to explore and navigate. This also helps new joiners to onboard faster, they do not have to understand the whole system to be able to contribute to the project.

Easy to test

With isolated features, it’s much easier to set up and write tests (unit or instrumentation) for a specific feature in isolation without having to deal with the dependencies for the rest of the app.

Fast CI builds

A monolithic project means running all tests on CI on every merge request, this can be a bit costly and slow with a big number of contributors and merge requests. By modularizing the app, we are also modularizing the tests and we can optimize the pipeline by running the tests in a module only if that module (or a dependant module) has changed. Also with caching, CI does not have the recompile cached and not modified modules.

Low Complexity

When all the code is living in one single module and that module gets bigger and bigger, the complexity of the app increases and becomes complex, unmaintainable, simple features take longer to do and refactoring starts to look like an exhausting task. With modularization, the app is broken down into smaller pieces that can be maintained, refactoring and code migration are simpler and less risky, trying new things become easier (Like trying jetpack compose for example).

Approach

The approach that we decided on at OLX is based on the 3 layers:

Modularization layers

App module:

The app module is the main module that binds all the modules together and the orchestrator dependencies and flavours.

Feature module:

A module that contains a single feature and represents a specific flow in accordance with business logic. The feature in our case is split to 3 modules, public, implementation (or impl) and impl-wiring.

  • public module that has the interface or the contract of how to interact with this feature.
  • impl module that contains the full implementation of the feature
  • impl-wiring module that wires the interface to the implementation and provides the dependencies

This split is done for three reasons. The first reason is to have a proper way of interacting with the feature without knowing the internals. The second reason is to avoid the recompilation of all dependent modules with frequent changes to the feature implementation. And the third reason is to be able to wire a concrete implementation or mocks for test purposes.

Common modules:

This layer is responsible for the common components that multiple features will need like the design system, common data models, interfaces, and reusable helpers or components.

Real case example

OLX Pay & Ship:

The OLX app is a multi-market or multi-flavour app. Due to market specifics, we can have some differences in a feature for different markets, and in other cases, we want to experiment and run an A/B test in the same market or in different markets. The example below is about a feature we call “Buy with delivery”, and the screenshot represents the feature in two different markets, Romania and Poland.

Buy with delivery widget in the middle (Left is for Romanian market and right is for Polish market)

Gradle module diagram for the delivery module

Gradle module diagram for the delivery

The public module contains the interface for building the view and is added to the app module as a dependency.

And each variant has its impl and impl-wiring modules. The impl module contains the concrete implementation of the feature, while the impl-wiring provides the dependency bindings for the implementation. The Gradle dependencies are defined this way in the app module

This approach helps with market specifics and market experimentation, but also makes communication between features more defined. While a feature does not know anything about other features implementation, it only knows about the interface that acts as a contract between them.

Learnings

Gradle

Managing the dependencies efficiently, avoiding circular dependencies, and unifying the dependencies versions across all modules is a must for a successful modularization, in our case we are using a dependencies file under buildSrc that’s accessible by all modules where we keep the libraries versions in it. Gradle catalogue is a promising alternative once it’s production-ready.

Styles and resources

Making our design system available to any feature that requires it was the main reason for extracting it first into its own module. This made breaking down the monolith a bit easier.

DI

Managing and providing dependencies between modules is one of the most important pieces of a successful modularization. Feature modules will have their DI modules definition in the impl-wiring (This also helps with providing mocks instead of production dependencies) while the common dependencies will be defined in the main app module.

Navigation

There are many ways of achieving navigation between features, it could be through interfaces where redirection is defined in the wiring module. Could be also done with intent actions defined in the Manifest. Or with jetpack navigation if you’re using that.

Conclusion

I remember three years ago when we were 3 android engineers working on the main OLX app without modularization, a clean compilation of the app from back then would take on average 5 minutes and 30 seconds. Now with 16 contributors, a lot more features added and 50 modules in total, it takes only 3 minutes for a clean build to finish. Frequent merge conflicts are a thing of the past, features are well isolated and owned by teams. Considering those results and the ones discussed previously, this journey was a successful way to enable a smooth scaling at OLX. If you want to learn more about this process, join me at our live webinar on December 16 at 5PM GMT, save your spot here.

0 Comments
Submit a Comment

Continue reading

Share This