10 Gradle best practices to supercharge your project

Gradle best practices

When you use a build tool like Gradle, there are often many ways to do the same thing. How can you decide? Experience counts for a lot, but for something more practical I’ve compiled this list of 10 essential Gradle best practices. If you incorporate these into your project, you’ll have a good chance of success.

1. Always use the Gradle wrapper

The wrapper is a special script included in your project which handles downloading the correct Gradle version and executing a build.

This sounds simple, but it has 3 big advantages.

  1. you don’t need Gradle installed locally to run your build
  2. you always use the version of Gradle supported by the project.
  3. it’s easy to update the Gradle version

If you don’t currently have the wrapper in your project, you can add it by running gradle wrapper.

Just remember to always execute a build with ./gradlew <task-name> in Linux or gradlew.bat <task-name> in Windows, then you can’t go wrong.

Keep the Gradle elephant happy and always use the wrapper!

2. Stop cleaning your project

If there’s one way to guarantee you’ll waste tonnes of time, it’s to run a clean every time you do a build. 🧹

Here’s how it normally looks:

./gradlew clean build

And is sometimes accompanied by general complaints about how slow the build is.

Gradle has an awesome feature called incremental build which means if you change something in your project and run a build, it only runs the necessary tasks based on that change. For example, if you modify a test class, Gradle doesn’t need to recompile your production code. Incremental build means small changes normally result in a very fast build, helping developers get more work done.

When you run clean there’s no way that Gradle can use the incremental build feature, and the Gradle elephant becomes very sad.

Deleted files

Another scenario where sometimes people think clean is required is if files are deleted. Here’s how one popular post from StackOverflow put it.

“Similar side effects can happen also with resources/classes removed from the sources but remained in the build folder that was not cleaned.”

An incorrect assumption about Gradle from StackOverflow

Of course, the desired outcome if a file is deleted is that the corresponding compiled class or copied resource created in the build directory should also be removed.

Gradle does this already! In Gradle speak it’s called cleaning stale outputs.

Save yourself some time and stop cleaning your project!

3. Always add settings.gradle

Partner in crime with build.gradle, the settings.gradle file is usually found in the root of a project.

It specifies, amongst other things, the project name and any subprojects to be added to your build.

Here’s an example.

rootProject.name = 'settings-example'

include 'some-subproject'

If at this point you’re wondering what is this mysterious file, this tip is for you!

If not, did you know that settings.gradle is optional?

Yes, you can omit the file, in which case Gradle uses a project name based on the directory name. That’s not very helpful, because if your project is ever cloned to a differently named directory, its name will be incorrect. This could happen on a CI server, for example.

There are also performance implications. If you leave out settings.gradle, Gradle recursively navigates up the directory tree looking for such a file. That could be a lot of unnecessary file reads.

Save yourself from future pain and always include a settings.gradle file!

4. Move tasks to buildSrc

If you’re anything like me, when there’s a lot of clutter in build.gradle things are really difficult to understand. That’s why Team Gradle invented the buildSrc directory.

The buildSrc directory lives in the root of your project, and can contain Groovy, Kotlin, or Java source code. If you have any task code in your build.gradle, it’s ripe for moving over to buildSrc for 3 main reasons:

  • clean up your build.gradle, making it easier to understand
  • separate your task implementation from declaration
  • for multi-project builds, your task can be used in other subprojects

Here’s an example of an unnecessarily verbose build.gradle.

abstract class RollercoasterTask extends DefaultTask {
    @Input
    abstract Property<String> getFavouriteCoaster()

    RollercoasterTask() {
        favouriteCoaster.convention('Space mountain')
    }

    @TaskAction
    def tellMeMyFavourite() {
        println "Your favourite coaster is ${favouriteCoaster.get()}!"
    }
}

tasks.register('coaster', RollercoasterTask) {
    favouriteCoaster = 'Super-duper loopy coaster'
}

As the build.gradle grows, this kind of code can send your head in a spin. Let’s create the buildSrc directory at the same level as build.gradle, with the following structure beneath it.

├── build.gradle
├── buildSrc
│   └── src
│       └── main
│           └── groovy
│               └── com
│                   └── tomgregory
│                       └── RollercoasterTask.groovy

We can almost lift-and-shift the class definition from build.gradle into RollercoasterTask.groovy. We just have to include the relevant package and import statements.

package com.tomgregory

import org.gradle.api.DefaultTask
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction

abstract class RollercoasterTask extends DefaultTask {
    @Input
    abstract Property<String> getFavouriteCoaster()

    RollercoasterTask() {
        favouriteCoaster.convention('Space mountain')
    }

    @TaskAction
    def tellMeMyFavourite() {
        println "Your favourite coaster is ${favouriteCoaster.get()}!"
    }
}

In build.gradle we can replace the class definition with a single import statement. Much nicer!

import com.tomgregory.RollercoasterTask

tasks.register('coaster', RollercoasterTask) {
    favouriteCoaster = 'Super-duper loopy coaster'
}

Give you and your colleagues some peace of mind by moving task class definitions to the buildSrc directory!

5. Run tests in parallel

There’s nothing that kills productivity like a slow suite of unit tests. Why not make the most of your available CPU cores, and run tests in parallel?

It’s easy to get tests running in parallel, with this small addition to build.gradle.

test {
    maxParallelForks 3
}

With this in place, Gradle will allocate each of your test classes to one of up to three test executors.

Let’s do one of those impactful before-and-after examples, using this sample Java project with 3 test classes.

Before parallel tests

When running using a single thread the tests take 11s to execute.

$ ./gradlew cleanTest test

BUILD SUCCESSFUL in 11s
3 actionable tasks: 2 executed, 1 up-to-date

After parallel tests

With the maxParallelForks configuration applied, our tests go into turbo mode and execute in only 5 seconds! 🚀

$ ./gradlew cleanTest test

BUILD SUCCESSFUL in 5s
3 actionable tasks: 2 executed, 1 up-to-date

Of course, the improvements you get will depend on your specific project and the machine you’re running the tests on. Gradle won’t run more executors than there are CPU cores available.

To avoid any strange side-effects, you should make sure that your tests don’t have any interdependencies before running them in parallel.

Share the load and run your tests in parallel!

6. Version your project

Versioning your Gradle project makes it easier to understand when changes have been introduced. This is especially important when others are consuming your project, as is the case when you’re creating a library or tool.

For example, Gradle itself uses a fairly standard version number system which includes a major, minor, and patch version.

The benefit of this is that Gradle users, such as you and I, can easily understand the scope of changes when upgrading Gradle. When the major version increases, this indicates that there may be breaking changes and we should read the release notes.

In Gradle, setting a version number is done in the build.gradle like this.

version = '0.1.0'

One approach to versioning would be that whenever you do a release you manually change this version number appropriately. There’s a better way though, which doesn’t involve having to edit and commit build.gradle.

Versioning with tags

Most projects on GitHub use tags for the version number. Here’s the tag for the latest release of Gradle, at the time of writing.

To implement something similar in your own project, you could use the axion-release plugin. Once applied to your project, it makes releasing easy through several new tasks.

Apply the plugin like this.

plugins {
    // any other plugins
    id 'pl.allegro.tech.build.axion-release' version '1.13.2'
}

version = scmVersion.version

Run the currentVersion task to see what version your project has. If you don’t currently have any tags, it defaults to 0.1.0-SNAPSHOT.

$ ./gradlew currentVersion

> Task :currentVersion

Project version: 0.1.0-SNAPSHOT

BUILD SUCCESSFUL in 1s
1 actionable task: 1 executed

Run the release task to tag your repository.

$ ./gradlew release

> Task :verifyRelease
Looking for uncommitted changes..
Skipping ahead of remote check
Checking for snapshot versions..

> Task :release
Creating tag: v0.1.0
Changes made to local repository only

BUILD SUCCESSFUL in 2s
2 actionable tasks: 2 executed

Now when you run currentVersion you’ll see the updated non-snapshot version number.

$ ./gradlew currentVersion

> Task :currentVersion

Project version: 0.1.0

BUILD SUCCESSFUL in 1s
1 actionable task: 1 executed

If you make any further commits to your project the version will automatically become 0.1.1-SNAPSHOT. You can configure the plugin to increment the major, minor, or patch version as you prefer.

Let others know about the cool stuff you’re releasing by versioning your project!

7. Encapsulate task declarations in a plugin

A task declaration is when we create an instance of some task class, normally configuring some task properties.

Here’s an example of a declaration of the Copy task in build.gradle, creating a task called copyQuote.

project.tasks.register('copyQuote', Copy) {
    from 'quote.txt'
    into "$project.buildDir/quotes"
    filter(ReplaceTokens, tokens: [CHARACTER: 'Tweedledee'])
}

The fact that this task is declared in build.gradle can cause several issues:

  • additional code makes it more difficult to understand build.gradle at a high level
  • there may be duplication if we declare similar tasks in different subprojects

The solution is to move the logic into a plugin. When applied in build.gradle, plugins normally add helpful tasks to your build.

If you only need to use the plugin from within your project (not other projects), you can define it in the buildSrc directory.

.
├── build.gradle
├── buildSrc
│   └── src
│       └── main
│           ├── groovy
│           │   └── com
│           │       └── tomgregory
│           │           ├── WonderlandPlugin.groovy

Here’s an example of moving the copyQuote task above into the plugin class.

package com.tomgregory

import org.apache.tools.ant.filters.ReplaceTokens
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.tasks.Copy

class WonderlandPlugin implements Plugin<Project> {
    void apply(Project project) {
        def extension = project.extensions.create('wonderland', WonderlandPluginExtension)

        project.tasks.register('copyQuote', Copy) {
            from 'quote.txt'
            into "$project.buildDir/quotes"
            filter(ReplaceTokens, tokens: [CHARACTER: extension.characterName.get()])
        }
    }
}

With Gradle plugins, an extension is a way to configure your plugin from within build.gradle. In this case we’re providing a way to configure the token replacement value during the copy, with the wonderland extension, which uses WonderlandPluginExtension.groovy.

Now the build.gradle can be greatly simplified.

plugins {
    id 'wonderland'
}

wonderland {
    characterName = 'Tweedledee'
}

Feel free to try out the full sample project on GitHub.

Promote reuse by extracting common build logic into plugins!

8. Use the latest Gradle version

The clever folks over at Gradle HQ are always adding new features and improvements, so why not take advantage of all that good stuff?

You can check Gradle releases for the latest version. Since we’re all using the Gradle wrapper now 😉 (see tip #1), update your project with ./gradlew wrapper --gradle-version <version>.

$ ./gradlew wrapper --gradle-version 7.1.1

BUILD SUCCESSFUL in 3s
1 actionable task: 1 executed

This command changes the Gradle version in the gradle/wrapper/gradle-wrapper.properties file. Next time you run a build, the version you specified will be downloaded from the internet and cached locally.

Don’t forget to commit any changes to your Gradle wrapper files back into version control, so everyone’s using the same version of Gradle.

To see what cool stuff is in the Gradle 7 major release, check out Top Gradle 7 features & improvements.

Get maximum Gradle goodness by using the latest version!

9. Optimise your repositories

Declaring repositories in build.gradle tells Gradle where it should looks for dependencies required to build your application.

For example, here we tell Gradle to look in my custom local Maven repository and Maven Central.

repositories {
    maven {
        name = 'tomRepo'
        url 'http://localhost:8081/repository/snapshots'
        allowInsecureProtocol true
        credentials(PasswordCredentials)
    }
    mavenCentral()
}

Let’s assume the same project requires these dependencies to build the Java application.

dependencies {
    implementation group: 'com.tom', name: 'artifact-to-publish', version: '1.0-SNAPSHOT'
    implementation 'commons-lang:commons-lang:2.6'
    implementation 'com.google.guava:guava:30.1.1-jre'
    implementation 'org.mapstruct:mapstruct:1.4.2.Final'
    implementation 'org.hibernate:hibernate-validator:7.0.1.Final'
}

The first dependency comes from my local Maven repository, but the others are found in Maven Central.

Let’s try building the application with --refresh-dependencies, forcing Gradle to go back to the repository to check for changes. We’ll also pass --info to see details of what’s happening.

$ ./gradlew build --refresh-dependencies --info | grep missing
Resource missing. [HTTP HEAD: http://localhost:8081/repository/snapshots/org/hibernate/hibernate-validator/7.0.1.Final/hibernate-validator-7.0.1.Final.pom]
Resource missing. [HTTP HEAD: http://localhost:8081/repository/snapshots/commons-lang/commons-lang/2.6/commons-lang-2.6.pom]
Resource missing. [HTTP HEAD: http://localhost:8081/repository/snapshots/org/mapstruct/mapstruct/1.4.2.Final/mapstruct-1.4.2.Final.pom]
Resource missing. [HTTP HEAD: http://localhost:8081/repository/snapshots/com/google/guava/guava/30.1.1-jre/guava-30.1.1-jre.pom]
Resource missing. [HTTP HEAD: http://localhost:8081/repository/snapshots/org/apache/commons/commons-parent/17/commons-parent-17.pom]
...and 31 more

The output shows that there are in fact 36 Resource missing errors, where Gradle looked for a dependency but couldn’t find it. Can you guess why?

Well it turns out that Gradle looks for dependencies in the listed repositories in the order in which the repositories are declared. In our case, since we’ve declared the local Maven repository first, Gradle looks there first before looking in Maven Central.

The simple fix is to reverse the order of the repositories.

repositories {
    mavenCentral()
    maven {
        name = 'tomRepo'
        url 'http://localhost:8081/repository/snapshots'
        allowInsecureProtocol true
        credentials(PasswordCredentials)
    }
}

Now the same build command only gives us 2 missing resources. On a small project this might not make much difference, but on larger projects that may rely on hundreds of dependencies the difference could be more significant.

Avoid unnecessary network requests by optimising the order of your repositories!

10. Never commit passwords

Did you ever commit a password into version control and receive the full wrath of the once friendly security adviser? 😠 I hope not, as it can be a thoroughly unpleasant experience.

That’s a thing of the past though, since Gradle offers many ways we can move credentials outside of a project.

A common use case is to pull dependencies from a private repository, which requires a username and password.

repositories {
    maven {
        name = 'tomsRepo'
        url 'https://tomgregory-299404798587.d.codeartifact.eu-west-1.amazonaws.com/maven/demo/'
        credentials(PasswordCredentials)
    }
}

If you set the repository name and specify credentials(PasswordCredentials) then Gradle will automatically look for properties <repositoryName>Username and <repositoryName>Password.

You could pass these on the command line or set them in ~/.gradle/gradle.properties, which for the above example would look like this.

tomsRepoUsername=aws
tomsRepoPassword=some-ridiculously-long-password

Another way to pass in a password is just to access a Gradle property directly.

repositories {
    maven {
        url 'https://tomgregory-299404798587.d.codeartifact.eu-west-1.amazonaws.com/maven/demo/'
        credentials {
            username 'aws'
            password property('mypw')
        }
    }
}

Once again, pass the password on the command line or in ~/.gradle/gradle.properties.

./gradlew build --refresh-dependencies -Pmypw=<password>

Keep your passwords secret by externalising your credentials!

More Gradle best practice tips

If you found these best practices useful, then why not check out other tips over on the Gradle best practice tips YouTube playlist?

More tips coming soon!

Get going with Gradle course
Gradle icon

Want to learn more about Gradle?
Check out the full selection of Gradle tutorials.

10 Gradle best practices to supercharge your project

Leave a Reply

Your email address will not be published. Required fields are marked *

Scroll to top

Get the newsletter

Found this article helpful? Subscribe for monthly updates.

✅ All of my latest articles for the month
✅ Access to video tutorials
✅ Exclusive tips not found on my website