Saying Goodbye to SNAPSHOTs with Gradle’s Composite Builds

There is a good chance that as a developer, you had to maintain a library. We all love libraries:

  • They allow us to reuse code in different projects.
  • They help break dependencies by concerns. Libraries (should) only tackle one problem, this means that creating libraries help us breaking our implementation into smaller problems.
  • They prevent code coupling by creating one-directional relationships. It is possible for our main project to depend on a library but no the other way around.

But all these benefits don’t come for free. Managing a library can be exhausting. Let’s use the Pizza Maker project as an example:

pizza-maker is a Gradle/Java project. For the sake of the example, we will be focusing on its dependencies. pizza-maker uses an internal library ingredient-validator.

pizza-maker dependencies

In order to maintain this library, we use semantic versioning and an artifact repository. This means that pizza-maker’s build.gradle contains something like this:

implementation "com.pizza:ingredient-validator:1.0.0"

Let’s say that we need to add a new method to the ingredient-validator library to check if an ingredient is valid:

public boolean isValidPizzaIngredient(String ingredient) {
return !"pineapple".equals(ingredient);
}

Once we have changed the code in the library, the traditional approach is to change the version of our library to a new SNAPSHOT version, 1.1.0-SNAPSHOT and release a new version of ingredient-validator using the following command:

./gradlew release

This will release a SNAPSHOT artifact to an external artifact repository or our local artifact repository, depending on our configuration.

Then, we will update our pizza-maker project build.gradle file:

implementation "com.pizza:ingredient-validator:1.1.0-SNAPSHOT"

Now we can test the integration between pizza-maker and ingredient-validator. Unfortunately, we didn’t consider that our string could contain characters in uppercase. We need to update our code:

public boolean isValidPizzaIngredient(String ingredient) {
return !"pineapple".equalsIgnoreCase(ingredient);
}

And repeat the process, releasing another SNAPSHOT version of the library:

./gradlew release

After that we will recompile pizza-maker and test the change. Once everything is working as expected, we can update the version of our library to a final 1.1.0 version and then release:

./gradlew release

Finally, we need to update pizza-maker to use our new release version of the library:

implementation "com.pizza:ingredient-validator:1.1.0"

It took three releases, in our small example app, to ship our code. In a real-world scenario, the number of releases can be even more. This process is not technically challenging but it is error-prone and significantly reduces how fast we can ship new code. This is where Gradle’s Composite Builds comes to save the day.

Composite Builds is an incubating feature in Gradle, that allows builds to include other builds. Even better, Gradle allows us to easily switch between a traditional artifact dependency and a Composite Build on demand. Let’s see how it works!

To enable Composite Builds in our pizza-maker we only need to open settings.gradle and tell gradle what is the path to ingredient-validator:

includeBuild ('../ingredient-validator')

Done! The next time that we build pizza-maker, Gradle will use our local build of the library instead of the binary version in the artifact repository.

From now on we don’t need to release SNAPSHOTs to see local changes in our main project. Every change in the library will be visible in the main project. Using this new functionality, we can iterate as fast as we want without worrying about which part of the code is in a library and which not.

This is a huge improvement and it gets even better. IntelliJ (including Android Studio) has native support for Composite Builds. By adding the line above, we will be able to see ingredient-validator as a module every time we open our pizza-maker project. This also means that we can add breakpoints in our library and debug both projects from the same IntelliJ project.

Project before applying Composite Builds.
Project after applying Composite Builds.

Once we are happy with our code we can then remove that line and release a final artifact.

Ok, this is great but you might be thinking “I don’t want to be adding and removing that line — it sounds error prone”.

Luckily for us, Gradle allows conditional statements in the settings.gradle file. Using a simple if sentence and a hidden file (.composite-ebable) we can enable and disable Composite Builds on demand.

When the file is present, we will use composite builds:

When the file is not present, we will use the artifact repository:

We can do this just by adding the following code to our settings.gradle file:

if (file("../ingredient-validator/.composite-enable").exists()) {
includeBuild ('../ingredient-validator')
}

This code will prevent Composite Builds from being accidentally used unless we intentionally create the .composite-enable file in our library project.

We can easily build a small Gradle task to create this file:

task enableIngredientValidatorCompositeBuild {
group = 'Tools'
description = 'Enable Ingredient Validator composite build'
doLast {
new File("../ingredient-validator/.composite-enable").createNewFile()
}
}

And another task to delete the file:

task disableIngredientValidatorBuild {
group = 'Tools'
description = 'Disable Ingredient Validator composite build'
doLast {
File file = file("../ingredient-validator/.composite-enable")
if (file.exists()) {
file.delete()
}
}
}

Now our team can use composite builds without worrying about breaking our CI (Continuous integration) environment. While they still have the control to enable and disable Composite Builds using our new tasks:

./gradlew enableIngredientValidatorCompositeBuild

and

./gradlew disableIngredientValidatorCompositeBuild

Gradle’s Composite Builds brings the best of both worlds. It allows us to have our code decoupled and well organized in different libraries, without suffering the overhead associated with working on different codebases.

Using one IntelliJ project, instead of one per library has been a game changer. It has helped our team to move faster and it has significantly reduced the number of bugs and synchronization issues in our libraries.

Composite Builds is an essential tool to have in your Gradle arsenal. I can’t say it enough, go ahead and try it out!

With 💚 Carlos Palacin Rubio from the Groupon Android team. Special thanks to Juan Antonio, Samuel Guirado Navarro and Pratyush Kshirsagar for their help in this article.