Modularising Trendyol Android App for Build Efficiency

Mustafa Berkay Mutlu
Trendyol Tech
Published in
11 min readJun 24, 2022
Roof of a building in Liege-Guillemins, Liège, Belgium.
Photo by Daniel van den Berg on Unsplash

Everyday, Android developers at Trendyol build the Android apps times and times again. When we also include our CI pipeline, on average we build our apps around 300 times a day!

We built our apps 8700 times in the last 28 days

This number of builds makes it very important to have a short build time. Because a short feedback loop means better developer experience and faster product iterations.

We have a few ongoing initiatives to improve our build times and the developer experience. In this article we’ll look at one of the initiatives. We’ll look at how to organise Gradle modules to allow building in parallel to shorten the build time. You can apply some ideas to other platforms as well, but in this article we’ll look at Gradle modules/projects.

Let’s begin!

Gradle Basics

Modules get compiled bottom up

Below we see an imaginary project setup. Imagine each module contains these:

  • core: utils and common classes
  • session: use cases about user session, such as LoginUser and LogoutUser
  • analytics: classes to report tracking events
  • config: classes to get any configurations from remote (such as Firebase Remote Config)
Sample modules

Gradle compiles your modules bottom up. Because it uses the output of one module to build the next module. This output is Application Binary Interface or ABI for short. You can think of it as the module’s binary API.

Gradle can build in parallel

Build timeline of our sample modules

If you enabled parallel build then Gradle builds your modules in parallel. In our sample here is the compilation timeline:

  1. :core module
  2. :config, :analytics and :session will get compiled together in parallel
  3. :app module

Your requirements come

Let’s say in this project you get these new requirements:

  1. You need to send analytics events to different remotes based remote config
  2. You need to track session start and session end.
Your module structure and new requirements

What’s the first thing that comes to the mind? Adding direct dependencies:

Before (left) and after with direct dependencies (right)

But this creates a problem because we can no longer build in parallel. Gradle will compile each dependency in our graph one after the other, in a serial manner.

In this article we’ll see how to organise our modules to build in parallel while we still meet our requirements. First let’s look at the basic terms.

Graph edges and height

In graph theory each connecting line between modules (dependencies) are “edges”.

Graph edges

Each graph has a height that is the length of the longest path inside the graph, measured by edge count.

Graph heights

The graph on the left has height of 2 and it’s able to compile:b, :c and :d together in parallel.

The graph on the right has height of 4 and it has to compile all modules one after the other.

Even with the same number of modules, graphs with lower graph height are usually better. Because the lower graph height means Gradle will build more modules in parallel.

Changing a module requires compilation of dependant modules

Imagine that in our application we use a module called :library. Usually modules need other modules to work so imagine :library depends on :foo and :bar modules. In this case when we change a module, such as :bar, then Gradle will recompile all the modules depend on :bar. Red color indicates recompilation:

Changing bar module will cause recompilation of library, feature and app modules

This recompilation happens when you change the ABI of a module.

ABI is not what you write code against, for example this Kotlin value with a “const”

private const val REQUEST_CODE: Int = 42

will produce a different ABI than this Kotlin value without “const:

private val REQUEST_CODE: Int = 42

If you remove “const” then you don’t need to change call sites. But this change will produce a different ABI.

In this article, we assume that any change you do produces a different ABI and causes recompilation.

To wrap up this Gradle Basics section, remember these things:

  • Modules get compiled bottom-up
  • Shorter graph height means more parallel builds
  • Changing a module causes recompilation of dependant modules

How to shorten the graph height and avoid recompilation

How do we avoid long graph heights and recompiling many modules?

Let’s see what causes them:

  • We end up with a long graph height when:
    many modules depend on each other one after the other. This happens because you need the functionality of a module and it needs the functionality of other modules to work. This goes on and on.
  • We end up affecting many modules after making a change when:
    all those modules’ implementations depend on each other one after the other. When one implementation changes, it affect all the dependant implementations.

One way to solve them is to separate implementations from the public APIs (interfaces). Then depend only on the public APIs.

“Program to an interface, not an implementation.” — Gang of Four

In a direct dependency shown below, ClassA knows ClassB implementation.

Direct dependency

When dependency inversion is applied, ClassA no longer knows ClassB implementation.

Dependency inversion applied

Once we apply the dependency inversion principle, both classes start to depend on the public API, InterfaceB. We break the dependency between the implementations. After this change ClassA doesn’t know anything about how ClassB implements InterfaceB.

Here we applied dependency inversion to a class but to improve our project we need to apply it to whole modules.

Apply dependency inversion to a module

We can split a module into two:

  • “public” module: this module contains the public API of the module. Any public classes and interfaces the users would have to know to use your module.
  • “impl” module: this module contains the implementations of the interfaces in “public” module.

You can use any naming scheme you like. For example api/implementation, api/impl, public/implementation and public/impl. At Trendyol we went with api/impl naming scheme, see this Architecture Decision Record for more info.

Let’s see how it looks like compared to our previous module setup:

Split library into public and impl modules

Here we split :library module into :library:public and :library:impl.

  • Our feature module now depends on the public API of our module which is :library:public. Feature module doesn’t know anything the implementation details of the public API.
  • The code inside :library:impl module didn’t change, except we implemented the extracted interface (from :library:public). The underlying executed code, its behavior, its module dependencies and its tests all are same as before.
  • :library:impl module contains dependency injection wiring code that binds the implementation classes to interfaces. If you use Dagger you can think of this as Dagger module with “@Binds” annotated functions.
  • App module depends on :library:impl module and binds the implementation classes to interface classes using dependency injection.

Let’s see how the graph height is affected by this change:

Graph height of :app and :feature-2 modules reduced. Before (left) and after (right).

Note that both app modules and feature-2 module’s graph heights are reduced. :library:impl still has the same height because its dependencies haven’t changed.

Public modules can depend on other public modules if they need. They may have graph heights greater than 0. But a public module’s graph height always will be less than or equal to the graph height of its implementation module. In worst case height of :public and :impl modules will be the same.

:library:public module with height of 1 is still better

With this reduced height, we expect to have more parallel builds. Because now :feature-2 doesn’t have to wait until :library:impl gets compiled.

Let’s see how the recompilation has changed:

Before implementation module only (left), after with public/impl split (right)

We see that when we change the “impl” module, it doesn’t affect the feature module anymore. Because the public API that feature module is depending on is still the same. Gradle doesn’t have to recompile the feature module in this case.

But if we change the :library:public module, this will cause recompilation of all dependant modules, such as :feature-2, :library:impl and :app. This worst case of our new approach is the default case in the previous approach.

Dependency constraint

We use these dependency rules to make sure we don’t create problems:

  • public can depend on public
    We allow this. But you should make sure that a class from the other public module appears in this module’s public API. If it doesn’t appear then don’t depend on the other public module because it’s not required.
  • public can’t depend on impl
    We don’t allow this. Because the public modules only contain API definitions and they should be as small as possible.
  • impl can depend on public
    We allow this. There is absolutely no problem here. Implementation modules can use other module’s public APIs to use them.
  • impl can’t depend on impl
    We don’t allow this. Because it causes longer graph heights and more recompilations after a change.
Module constraints

Benefits of this setup are:

  • Gradle will build more of your modules in parallel. See this page to learn how parallel builds affects your build time.
  • Changing the implementation details won’t affect the consumer modules. This will result in less recompilation and more build cache hits.
  • Creating different implementations of a module will be easier now, because we extracted the API already.
  • Less likely to be in a circular dependency situation.

Meeting the Requirements

Do you remember at the beginning of the article we changed the dependencies of our modules like this in order to meet the requirements:

Before (left) and after (right)

Let’s revisit this and see if we can create a better setup by applying the dependency inversion principle.

We split :session, :analytics and :config into public and impl modules:

Before (with serial compilation on the left) and after (with parallel compilation on the right)

We ask ourselves:

  1. Do we need to depend on :config:public from :analytics:public?
    Modules depending on :analytics:public won’t care where and how we send our analytics events, they only want to report tracking events. So :config:public doesn’t have to be in the public API of analytics, :analytics:public.
    We still need to send tracking events to different remotes based on our config, so we depend on :config:public from :analytics:impl. Because sending events based on config is an implementation detail.
  2. Do we need to depend on :analytics:public from :session:public?
    Modules depending on :session:public won’t care if and where we send tracking events. They want to start and end session using the UseCases defined in that module. So :analytics:public doesn’t have to be in the public API of session, :session:public.
    We still need to track user session, so we depend on :analytics:public from :session:impl. Because tracking user session is an implementation detail.

Splitting a module into public and implementation

Identify a module to split

Josef Raska has a very useful Gradle plugin called Module Graph Assert.

Once you setup the plugin, you can get statistics about your project with this task:

./gradlew generateModulesGraphStatistics

This will output your project’s statistics like such as the graph height and the longest path in your graph:

GraphStatistics(modulesCount=12, edgesCount=22, height=4, longestPath=':app -> :features:product_details:library -> :libraries:android:core -> :libraries:inject:android -> :libraries:inject:core')

If you want to get the statistics of a specific module, you can select it like this:

./gradlew generateModulesGraphStatistics -Pmodules.graph.of.module=:feature-one

Once you get this info, you can pick a module from the longest path and split it into public and implementation. If there are no other paths with the same height, then breaking that module will reduce your project’s graph. There may be many paths with the same height. After you break one path you may need to break other paths before you can see a decrease in the graph height.

Split into public and impl

Once you identify a module to split, create a module called “public” and rename the existing module to “impl”.

Then you need to extract the public API to the separate “public” module. Public API means:

  • Any public functions that are access from outside of your module. You can put these into interfaces.
  • Any public classes, constant values.
  • Any documentation about the API of your module. Any READMEs or KDoc/Javadocs that is about the public API and not about the implementation.

One of the simplest example is splitting a module that contains a feature Fragment. For Fragments, you can create an interface with one function that takes any parameters you need and returns a Fragment.

You can pass any parameter into FeatureFragmentProvider#provideFeatureFragment function. Then add them to your fragment’s arguments bundle in FeatureFragmentProviderImpl.

Similarly you can create IntentProvider interfaces for your Activities.

If you have any type of class that has a public access, then you need to split them as interface and implementation. Alternative is to move the class to another module. In some cases moving the class to another module makes more sense.

For example, let’s look at this example SendUserReview use case class:

Organise the Gradle Dependencies

The Java plugin in Gradle provides us a few configurations such as:

  • implementation: your dependency will only get added to current module
  • api: your dependency will get added to current module and any module that depends on your module

My recommendation is:

  • For public modules: use api configuration for your dependencies. Because public module consists only the public API (public functions, parameters, return types). Any module that depends on the public module has to know those dependencies. When we use api configuration, consumer modules will get the necessary dependencies with no extra effort.
  • For impl modules: add the public module as an api dependency. Because any module that depends on impl module has to know the public module as well. Use implementation configuration for the rest of the dependencies. Because those dependencies are for implementation details.

Our Experience at Trendyol

At Trendyol, when we started splitting our modules like this, the graph height of the full app module was 30. After a few months of splitting and refactoring we reduced the height down to 26.

Since we started this, full app build times have been going down from 3 minutes 30 seconds to 2 minutes 20 seconds! It’s difficult to say our modularisation effort is the only contributor to this improvement. Because the build tooling, such as Gradle and Gradle plugins, has also improved over time. That said, we still see a correlation between low graph heights and low build times. At Trendyol we use sample apps to build and run only a small part of the full app. This is another initiative to reduce build times. Below you see a scatter chart that shows build times and graph heights of each app in the project.

We have more ongoing initiatives to reduce build times, like the sample apps. Those are for future articles, stay tuned for them! 🙂

More Info

Check these resources to learn more about this approach:

Want to work in this team?

Do you want to join us on the journey of building the e-commerce platform that has the most positive impact?

Have a look at the roles we’re looking for!

--

--