Demystifying Gradle

Bruna Pereira
7 min readAug 24, 2020

Versão em português aqui.

It is very easy to use a build tool without understanding 10% of what that large amount of code is doing. That is because generally the build settings of an application don’t change much, so if you start working on an existing project, chances are great that you won’t need to make big changes on that block of code.

So, I decided to write this article that demystifies some uses that we make in Gradle and that we don’t even understand very well where it comes from.

For the purposes of this article, I will use a Gradle script from an API in Kotlin. The script is written using the Gradle Kotlin DSL:

1. Language

As mentioned above, the script was written in Kotlin. Note that the file extension is .kts and if you’ve worked with gradle using groovy, you probably didn’t have that extension and the syntax was slightly different.

Back at the launch of Gradle version 3, it was announced that, for the new version, it would be possible to configure your build not only using Groovy — as we had until then — but also with a DSL written in Kotlin!

Among the several advantages described by Chris Beams in this article, the one that I liked the most was the possibility to visit the source code of the functionalities through the IDE, just as we do with the libraries that we use on a daily basis in Java or Kotlin.

2. Plugins

From lines 1 to 6 of the file above, we have the plugins definition block.
Several times I have seen people with doubts about what should be a plugin and what should be an application dependency.

The plugins are intended to extend the operation of Gradle itself, while the dependencies are extensions / libraries for your application.

Note that in the file there are three different forms of plugin declaration and I will explain below the common scenario:

id("org.unbroken-dome.test-sets") version "3.0.1"

Like dependencies, plugins have a name, composed of (group id and artifact id) and a version. This is the most common plugin definition syntax. Note that we could also define it as follows:

id("org.unbroken-dome.test-sets") version("3.0.1")

But id parentheses are required. This is because if you look at the source code, the version is an infix function of the kotlin, allowing a function of only one parameter to have the parentheses omitted at the time of invocation.

3. Repositories

This section will define where the dependencies of the project will be downloaded from. Now, how do you know where the library comes from or which repository you should use?

The truth is that from the point of view of those who are consuming dependencies, it does not matter where it comes from. In case you are publishing your own library, then you will have to choose where to publish.

The most popular repositories are those described on lines 12 and 13.
Maven Central is owned by Sonatype, the company that owns Nexus.
JCenter is from JFrog, the company that owns Artifactory.
Both are free for open source solutions.

JCenter contains all the dependencies that Maven Central has, plus some that are exclusively in JCenter. So, for the use of public repositories, there is no need to use the two together, JCenter would be enough.

4. Dependencies

This is probably the most modified section of the gradle scripts ever. Here we will define all the external libraries that we will use in our projects. As you can see, there are several ways to declare a dependency in the example above, and they are: implementation, compile, runtimeOnly, testCompile, testImplementation andtestCompileOnly.

4.1 Kotlin Library

Let’s start by the most different. On line 17 we have:

implementation(kotlin("stdlib-jdk8"))

As shown in the plugins, here in the dependencies we also have a shorter way to define the kotlin dependency, when using DSL in Kotlin. This line is none other than:

implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")

Here we define the kotlin dependency, and the installed kotlin version can be omitted, because as of kotlin 1.1.2, the dependency assumes the same plugin version defined earlier.

4.2 Differences between compile and implementation

First, let’s differentiate the first two keywords: compile and implementation.

Before gradle 3, the only way to define a necessary dependency in compile-time was to use compile. Back in 2017, at the launch of gradle 3, it was announced that the compile would be depreciated.
This happened because the compile by default has the behavior of adding all dependencies in the library to the consumers’ classpath. Take a look at the following image:

Suppose I am developing a module called module C and it depends on module B, which depends on module A.

In scenario 1, we use compile. Module A is present in the classpath of module C, even though I never needed to use this dependency. In addition to directly impacting the size of module C that I am building, there will be a need to recompile if module A is updated.

In scenario 2, we use the implementation. Module A is invisible to module C, and if I need it for any reason, I will have to add it myself on my project’s dependencies. Any change in the version of module A is transparent to module C.

Gradle brought a new configuration along with this change. There is also the api configuration. Using this setting, you deliberately maintain the behavior of scenario 1. With the release of the two new settings, gradle recommends that you should no longer use compile. As it is on their website:

The compile configuration still exists but should not be used since it does not offer the guarantees that the api and implementation configurations provide.

4.3 Others configurations

There are a number of settings, as we saw in the example file. The following table shows all the dependency definition settings and their descriptions:

5. Tasks

There are basically 3 ways to create a task in the gradle:
1. It is a native task;
2. A plugin containing a task has been installed;
3. A task was created internally in Gradle itself.

In the first two scenarios, tasks can be extended, either to define some configuration of your project, or to change the behavior. And that is exactly what is happening on lines 41 to 57 of the file.

First, we are adding some settings to the gradle test task:

tasks.withType<Test> { 
useJUnitPlatform {
includeEngines(“spek2”)
} testLogging {
events(“passed”, “skipped”, “failed”)
}
}

What can be modified in a task will be very specific, you just have to read the documentation for the plugin that defined that task or even the gradle documentation for native tasks. Here you will find all possible configurations for the configuration of the test task used above.

Usually the task type has the same name as the task itself. In the example above, we are modifying the task that is being executed using the gradle test command.

In lines 50 to 57 we have a slightly different syntax, but the truth is that it does exactly the same thing.

In the example file, we have the following code block:

tasks {    
compileKotlin {
kotlinOptions.jvmTarget = "1.8"
}
compileTestKotlin {
kotlinOptions.jvmTarget = "1.8"
}
}

And this code can be written using exactly the same syntax that we used for the test task, like this:

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
#...
# other configurations and tasks
#...
tasks.withType<KotlinCompile> {
kotlinOptions {
jvmTarget = "1.8"
}
}

And in fact we don’t even need to define the target version for both tasks, the same version that is assigned to the compile task should be used to compile both the source code and the tests.

Note that we had to import the KotlinCompile task in order to configure it. This is because unlike the previous example, this task is defined by a plugin and is not internal to Gradle. So, we have to explicitly import it into our script.

Oh, and if you don’t know exactly why this configuration is there, here’s the explanation: Kotlin from version 1.1 can compile the code for bytecode compatible with JVM versions 1.6 or newer.
By default Kotlin will always convert to version 1.6, but if you are going to run your code on a server with the JVM in a newer version, you can pass a parameter to the compiler, indicating which version you would like to use.

6. Configurations

In the plugins sections, we mentioned that plugins are able to assign extra skills to your build script. We will often need to configure these plugins in our application, either to pass a parameter or to define desired behaviors.

Therefore, the plugins offer an api so that we can configure the settings.
On lines 59 and 63 we are doing exactly that: setting parameter for the plugins on lines 4 and 5.
Just like in the Tasks, for you to know what can be overwritten in this section, refer to the documentation.

Conclusion

Writing a build script requires a lot of knowledge about the API of the tool you are using, but it is not essential.
It is entirely possible to create a build script based on trial and error, copying and pasting code from the documentation and from forums we find online, but to achieve efficiency in your build script and also be able to organize the code, you need to understand the features you are using.

And you, do you have any block of code in the build script of a project that you are working on now and have no idea what it does? Go there and check it out!

References:

https://docs.gradle.org/current/userguide/userguide.html
https://docs.gradle.org/current/userguide/java_library_plugin.html`

--

--