Gradle has a rich API with several approaches to creating build logic. The associated flexibility can easily lead to unnecessarily complex builds with custom code commonly added directly to build scripts. In this chapter, we present several best practices that will help you develop expressive and maintainable builds that are easy to use.

The third-party Gradle lint plugin helps with enforcing a desired code style in build scripts if that’s something that would interest you.

Avoid using imperative logic in scripts

The Gradle runtime does not enforce a specific style for build logic. For that very reason, it’s easy to end up with a build script that mixes declarative DSL elements with imperative, procedural code. Let’s talk about some concrete examples.

  • Declarative code: Built-in, language-agnostic DSL elements (e.g. Project.dependencies{} or Project.repositories{}) or DSLs exposed by plugins

  • Imperative code: Conditional logic or very complex task action implementations

The end goal of every build script should be to only contain declarative language elements which makes the code easier to understand and maintain. Imperative logic should live in binary plugins and which in turn is applied to the build script. As a side product, you automatically enable your team to reuse the plugin logic in other projects if you publish the artifact to a binary repository.

The following sample build shows a negative example of using conditional logic directly in the build script. While this code snippet is small, it is easy to imagine a full-blown build script using numerous procedural statements and the impact it would have on readability and maintainability. By moving the code into a class, it can also be tested individually.

build.gradle.kts
if (project.findProperty("releaseEngineer") != null) {
    tasks.register("release") {
        doLast {
            logger.quiet("Releasing to production...")

            // release the artifact to production
        }
    }
}
build.gradle
if (project.findProperty('releaseEngineer') != null) {
    tasks.register('release') {
        doLast {
            logger.quiet 'Releasing to production...'

            // release the artifact to production
        }
    }
}

Let’s compare the build script with the same logic implemented as a binary plugin. The code might look more involved at first but clearly looks more like typical application code. This particular plugin class lives in the buildSrc directory which makes it available to the build script automatically.

ReleasePlugin.java
package com.enterprise;

import org.gradle.api.Action;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.Task;
import org.gradle.api.tasks.TaskProvider;

public class ReleasePlugin implements Plugin<Project> {
    private static final String RELEASE_ENG_ROLE_PROP = "releaseEngineer";
    private static final String RELEASE_TASK_NAME = "release";

    @Override
    public void apply(Project project) {
        if (project.findProperty(RELEASE_ENG_ROLE_PROP) != null) {
            Task task = project.getTasks().create(RELEASE_TASK_NAME);

            task.doLast(new Action<Task>() {
                @Override
                public void execute(Task task) {
                    task.getLogger().quiet("Releasing to production...");

                    // release the artifact to production
                }
            });
        }
    }
}

Now that the build logic has been translated into a plugin, you can apply it in the build script. The build script has been shrunk from 8 lines of code to a one liner.

build.gradle.kts
plugins {
    id("com.enterprise.release")
}
build.gradle
plugins {
    id 'com.enterprise.release'
}

Avoid using internal Gradle APIs

Use of Gradle internal APIs in plugins and build scripts has the potential to break builds when either Gradle or plugins change.

The following packages are listed in the Gradle public API definition and the Kotlin DSL API definition, except any subpackage with internal in the name.

Gradle API packages
org.gradle
org.gradle.api.*
org.gradle.authentication.*
org.gradle.build.*
org.gradle.buildinit.*
org.gradle.caching.*
org.gradle.concurrent.*
org.gradle.deployment.*
org.gradle.external.javadoc.*
org.gradle.ide.*
org.gradle.ivy.*
org.gradle.jvm.*
org.gradle.language.*
org.gradle.maven.*
org.gradle.nativeplatform.*
org.gradle.normalization.*
org.gradle.platform.*
org.gradle.plugin.devel.*
org.gradle.plugin.use
org.gradle.plugin.management
org.gradle.plugins.*
org.gradle.process.*
org.gradle.testfixtures.*
org.gradle.testing.jacoco.*
org.gradle.tooling.*
org.gradle.swiftpm.*
org.gradle.model.*
org.gradle.testkit.*
org.gradle.testing.*
org.gradle.vcs.*
org.gradle.work.*
org.gradle.workers.*
org.gradle.util.*
Kotlin DSL API packages
org.gradle.kotlin.dsl
org.gradle.kotlin.dsl.precompile

Alternatives for oft-used internal APIs

To provide a nested DSL for your custom task, don’t use org.gradle.internal.reflect.Instantiator; use ObjectFactory instead. It may also be helpful to read the chapter on lazy configuration.

Don’t use org.gradle.api.internal.ConventionMapping. Use Provider and/or Property. You can find an example for capturing user input to configure runtime behavior in the implementing plugins section.

Instead of org.gradle.internal.os.OperatingSystem, use another method to detect operating system, such as Apache commons-lang SystemUtils or System.getProperty("os.name").

Use other collections or I/O frameworks instead of org.gradle.util.CollectionUtils, org.gradle.util.internal.GFileUtils, and other classes under org.gradle.util.*.

Gradle plugin authors may find the Designing Gradle Plugins subsection on restricting the plugin implementation to Gradle’s public API helpful.

Follow conventions when declaring tasks

The task API gives a build author a lot of flexibility to declare tasks in a build script. For optimal readability and maintainability follow these rules:

  • The task type should be the only key-value pair within the parentheses after the task name.

  • Other configuration should be done within the task’s configuration block.

  • Task actions added when declaring a task should only be declared with the methods Task.doFirst{} or Task.doLast{}.

  • When declaring an ad-hoc task — one that doesn’t have an explicit type — you should use Task.doLast{} if you’re only declaring a single action.

  • A task should define a group and description.

build.gradle.kts
import com.enterprise.DocsGenerate

tasks.register<DocsGenerate>("generateHtmlDocs") {
    group = JavaBasePlugin.DOCUMENTATION_GROUP
    description = "Generates the HTML documentation for this project."
    title = "Project docs"
    outputDir = layout.buildDirectory.dir("docs")
}

tasks.register("allDocs") {
    group = JavaBasePlugin.DOCUMENTATION_GROUP
    description = "Generates all documentation for this project."
    dependsOn("generateHtmlDocs")

    doLast {
        logger.quiet("Generating all documentation...")
    }
}
build.gradle
import com.enterprise.DocsGenerate

def generateHtmlDocs = tasks.register('generateHtmlDocs', DocsGenerate) {
    group = JavaBasePlugin.DOCUMENTATION_GROUP
    description = 'Generates the HTML documentation for this project.'
    title = 'Project docs'
    outputDir = layout.buildDirectory.dir('docs')
}

tasks.register('allDocs') {
    group = JavaBasePlugin.DOCUMENTATION_GROUP
    description = 'Generates all documentation for this project.'
    dependsOn generateHtmlDocs

    doLast {
        logger.quiet('Generating all documentation...')
    }
}

Improve task discoverability

Even new users to a build should to be able to find crucial information quickly and effortlessly. In Gradle you can declare a group and a description for any task of the build. The tasks report uses the assigned values to organize and render the task for easy discoverability. Assigning a group and description is most helpful for any task that you expect build users to invoke.

The example task generateDocs generates documentation for a project in the form of HTML pages. The task should be organized underneath the bucket Documentation. The description should express its intent.

build.gradle.kts
tasks.register("generateDocs") {
    group = "Documentation"
    description = "Generates the HTML documentation for this project."

    doLast {
        // action implementation
    }
}
build.gradle
tasks.register('generateDocs') {
    group = 'Documentation'
    description = 'Generates the HTML documentation for this project.'

    doLast {
        // action implementation
    }
}

The output of the tasks report reflects the assigned values.

> gradle tasks

> Task :tasks

Documentation tasks
-------------------
generateDocs - Generates the HTML documentation for this project.

Minimize logic executed during the configuration phase

It’s important for every build script developer to understand the different phases of the build lifecycle and their implications on performance and evaluation order of build logic. During the configuration phase the project and its domain objects should be configured, whereas the execution phase only executes the actions of the task(s) requested on the command line plus their dependencies. Be aware that any code that is not part of a task action will be executed with every single run of the build. A build scan can help you with identifying the time spent during each of the lifecycle phases. It’s an invaluable tool for diagnosing common performance issues.

Let’s consider the following incantation of the anti-pattern described above. In the build script you can see that the dependencies assigned to the configuration printArtifactNames are resolved outside of the task action.

build.gradle.kts
dependencies {
    implementation("log4j:log4j:1.2.17")
}

tasks.register("printArtifactNames") {
    // always executed
    val libraryNames = configurations.compileClasspath.get().map { it.name }

    doLast {
        logger.quiet(libraryNames.joinToString())
    }
}
build.gradle
dependencies {
    implementation 'log4j:log4j:1.2.17'
}

tasks.register('printArtifactNames') {
    // always executed
    def libraryNames = configurations.compileClasspath.collect { it.name }

    doLast {
        logger.quiet libraryNames
    }
}

The code for resolving the dependencies should be moved into the task action to avoid the performance impact of resolving the dependencies before they are actually needed.

build.gradle.kts
dependencies {
    implementation("log4j:log4j:1.2.17")
}

tasks.register("printArtifactNames") {
    val compileClasspath: FileCollection = configurations.compileClasspath.get()
    doLast {
        val libraryNames = compileClasspath.map { it.name }
        logger.quiet(libraryNames.joinToString())
    }
}
build.gradle
dependencies {
    implementation 'log4j:log4j:1.2.17'
}

tasks.register('printArtifactNames') {
    FileCollection compileClasspath = configurations.compileClasspath
    doLast {
        def libraryNames = compileClasspath.collect { it.name }
        logger.quiet libraryNames
    }
}

Avoid using the GradleBuild task type

The GradleBuild task type allows a build script to define a task that invokes another Gradle build. The use of this type is generally discouraged. There are some corner cases where the invoked build doesn’t expose the same runtime behavior as from the command line or through the Tooling API leading to unexpected results.

Usually, there’s a better way to model the requirement. The appropriate approach depends on the problem at hand. Here’re some options:

  • Model the build as multi-project build if the intention is to execute tasks from different modules as unified build.

  • Use composite builds for projects that are physically separated but should occasionally be built as a single unit.

Avoid inter-project configuration

Gradle does not restrict build script authors from reaching into the domain model from one project into another one in a multi-project build. Strongly-coupled projects hurts build execution performance as well as readability and maintainability of code.

The following practices should be avoided:

Externalize and encrypt your passwords

Most builds need to consume one or many passwords. The reasons for this need may vary. Some builds need a password for publishing artifacts to a secured binary repository, other builds need a password for downloading binary files. Passwords should always kept safe to prevent fraud. Under no circumstance should you add the password to the build script in plain text or declare it in gradle.properties file in the project’s directory. Those files usually live in a version control repository and can be viewed by anyone that has access to it.

Passwords together with any other sensitive data should be kept external from the version controlled project files. Gradle exposes an API for providing credentials in ProviderFactory as well as Artifact Repositories that allows to supply credential values using Gradle properties when they are needed by the build. This way the credentials can be stored in the gradle.properties file that resides in the user’s home directory or be injected to the build using command line arguments or environment variables.

If you store sensitive credentials in user home’s gradle.properties, consider encrypting them. At the moment Gradle does not provide a built-in mechanism for encrypting, storing and accessing passwords. A good solution for solving this problem is the Gradle Credentials plugin.

Don’t anticipate configuration creation

Gradle will create certain configurations, such as default or archives, using a "check if needed" strategy. That means it will only create these configurations if they do not already exist.

You should not ever create these configurations yourself. Names such as these, and the names of configurations associated with source sets, should be considered implicitly "reserved". The exact list of reserved names depends on which plugins are applied and how your build is configured.

This situation will be announced with the following deprecation warnings:

Configuration customCompileClasspath already exists with permitted usage(s):
	Consumable - this configuration can be selected by another project as a dependency
	Resolvable - this configuration can be resolved by this project to a set of files
	Declarable - this configuration can have dependencies added to it
Yet Gradle expected to create it with the usage(s):
	Resolvable - this configuration can be resolved by this project to a set of files

Gradle will then attempt to mutate the allowed usage to match the expected usage and will emit a second warning:

Gradle will mutate the usage of this configuration to match the expected usage. This may cause unexpected behavior. Creating configurations with reserved names has been deprecated. This is scheduled to be removed in Gradle 9.0. Create source sets prior to creating or accessing the configurations associated with them.

Some configurations may have their usage locked against mutation. In this case your build will fail and this warning will be immediately followed by an exception with the message:

Gradle cannot mutate the usage of configuration 'customCompileClasspath' because it is locked.

If you encounter this error you must either:

  1. Change the name of your configuration to avoid the conflict.

  2. If changing the name is not possible, ensure the allowed usage (consumable, resolvable, declarable against) for your configuration is aligned with Gradle’s expectations.

As a best practice, you should not "anticipate" configuration creation - let Gradle create the configuration first and then adjust it. Or, if possible, use non-conflicting names for your custom configurations by renaming them when you see this warning.