Impact of Dependency Tree Depth on Gradle Builds

Introduction

Great to see you take an interest to understand the impact of dependency tree depth on your Gradle builds.

After you finish reading this article here is what you’ll take away, depending on where you are with your project setup

  • If you are new to the concept of modularization, you’ll have a better understanding of how to structure your dependencies when you modularize your project, to get faster Gradle builds.
  • If you are working on a modularized project, you’ll get insights to further improve your build times by shortening your dependency tree, to speed up your Gradle builds.

Acquainting with Gradle Fundamentals

What is Gradle?

Gradle is an open-source build automation tool that allows you to build your software. You can read more about the design principles and terminology on their official website.

At the core of Gradle is a language for dependency-based programming.

A Gradle build is made up of Tasks. These tasks can depend on each other. They are executed in order of their dependencies. Together these tasks form a Directed Acyclic Graph. During a build, each task only runs once.

🧠
Gradle computes the dependency graph before any single task is executed.

What is a Task?

Simply put it is an atomic piece of work that you want to do as a part of your build process.

Since the work may or may not be done in a single step. A task is made up of one or more  Actions. As a result of its work, a task can produce TaskOutputs. Since a task might need to know some information to perform its work, they can also accept TaskInputs.

🧠
This information about Task Inputs and Outputs will come in handy in the next section where I discuss Task Output Caching.

What is Task Output Cache?

Imagine you create a Gradle task to calculate the sum of ASCII values until the character Z.

The input of the task is a character between A-Z.

Let’s say when you first execute the task, you give it A as input. Gradle executes the task and computes the result. You get the output as 2015.  Now you run the task again with the same input.

This time instead of actually computing the result, Gradle just spits out 2015. How? This is what is called Task Output Caching.

🧠
Gradle is smart enough to know when it can reuse the output of certain tasks. This is will come in handy when we try to understand the impact of dependency tree depth in Gradle builds.

Understanding the Sample Project Structure

Gradle Supports Single and Multi Project builds, For this post, we are focusing on a modularized (i.e Multi-Project Build).

To showcase various scenarios, I created a simple Gradle project that you can check out on my GitHub profile. The project is called dep-height-example and it has 4 modules.

The dependency graph of the project looks like this

What happens when I change one of these modules? Does my build-time depend on which module I change? The answer is yes! We’ll see how a change in each module affects the build.

Understanding The Impact of Change in Modules

Before we begin, I want to do a clean build to see all the tasks Gradle will run for our project.

./gradlew clean build

The image below shows all the tasks that were executed by Gradle.

💡
The title says 71 tasks executed but it shows only 6 tasks, why?

This is because, on the build scan summary, Gradle shows the top 6 tasks that took the longest time to execute.

Next, I will be making small changes in classes present in different modules and see the impact.

I can already tell you that the impact of this change will be different based on where the module lives in the dependency tree.

Change at the Top Of The Tree

First I will make a change in the :developer module that lives on the top of the dependency tree as shown below.

The change will be simple. I will add a new single line that invokes a println() method. Now when I build the project which modules do you think will get compiled?

Gradle only compiles the :developer module as you can see in the Build Scan output below.

This happened because none of the dependencies of the :developer module changed. So Gradle was able to reuse the cached task outputs for :contractor, :foundation and :wall modules from a previous build.

Let's move down our dependency tree and see what happens!

Change in the Middle of the Tree

In this case, we will modify the :contractor module.

The change here is identical to the previous step. I add a new single line that invokes a println() method. Now when I build the project which modules do you think will get compiled?

The answer is :contractor and :developer. If you guessed them, great job you are starting to understand the impact of dependency tree depth on gradle builds.

Let's move further down the tree to see what happens when you modify a module that exists at the bottom of the dependency tree.

Change at the Bottom of the Tree

Although :foundation and :wall both are considered the bottommost nodes.

In this case, we will modify the :wall module and add a new interface to :wall the module. This is to show you what effect it will have since :developer depends on this both directly and indirectly. Which modules do you think will get compiled in this case?

If you answered :wall, :contractor and :developer congratulations, you've managed to understand the impact of dependency tree depth on Gradle builds.

Why did Gradle build :wall, :contractor and :developer modules? Since the inputs of the compileKotlin tasks for all of these changed. This prevented Gradled from reusing the cached outputs of this task from a previous run.

Important Points To Remember

🧠
When comparing the impact of changes based on the dependency tree depth on Gradle builds, it is important to keep the size of modules in mind and not just their level in the dependency tree.
  • Modularization is great and should have a positive impact on your Gradle builds if implemented correctly.
  • It is very easy to modularize your app incorrectly and negatively impact your Gradle builds.
  • If a module in your graph changes frequently, ensure that not a lot of other modules depend on it i.e Reduce the Out-Degree of that Module.
  • Your goal should be to optimize for maximum Gradle task cache hits when trying to improve build speed.
  • Having a really deep dependency graph does not always mean it is bad.
  • More changes you introduce at the bottom of the graph, the less caching benefit you get.

Conclusion

To sum it up, if you want to improve your Gradle build times, think about modularization and reducing the out-degree of your modules. A deep dependency graph is not always a bad thing - more changes at the bottom of the graph just mean less caching benefit. Keep in mind what your goal is - optimizing for maximum Gradle task cache hits.

Learn in-depth about Gradle Dependency Trees and Five Ways to Generate Gradle Dependency Tree.

Subscribe to my blog and never miss an update on new articles about Android and Gradle.