Best practices for authoring maintainable builds
- Avoid using imperative logic in scripts
- Avoid using internal Gradle APIs
- Follow conventions when declaring tasks
- Improve task discoverability
- Minimize logic executed during the configuration phase
- Avoid using the
GradleBuild
task type - Avoid inter-project configuration
- Externalize and encrypt your passwords
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.
if (project.findProperty('releaseEngineer') != null) {
tasks.register('release') {
doLast {
logger.quiet 'Releasing to production...'
// release the artifact to production
}
}
}
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.
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.
plugins {
id 'com.enterprise.release'
}
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, with the exception of any subpackage with internal
in the name:
org/gradle/* org/gradle/api/** org/gradle/authentication/** org/gradle/buildinit/** org/gradle/caching/** org/gradle/concurrent/** org/gradle/deployment/** org/gradle/external/javadoc/** org/gradle/ide/** org/gradle/includedbuild/** org/gradle/ivy/** org/gradle/jvm/** org/gradle/language/** org/gradle/maven/** org/gradle/nativeplatform/** org/gradle/normalization/** org/gradle/platform/** org/gradle/play/** org/gradle/plugin/devel/** org/gradle/plugin/repository/* 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/workers/**
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.
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...')
}
}
import com.enterprise.DocsGenerate
tasks.register<DocsGenerate>("generateHtmlDocs") {
group = JavaBasePlugin.DOCUMENTATION_GROUP
description = "Generates the HTML documentation for this project."
title.set("Project docs")
outputDir.set(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.
tasks.register('generateDocs') {
group = 'Documentation'
description = 'Generates the HTML documentation for this project.'
doLast {
// action implementation
}
}
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.
dependencies {
implementation 'log4j:log4j:1.2.17'
}
tasks.register('printArtifactNames') {
// always executed
def libraryNames = configurations.compileClasspath.collect { it.name }
doLast {
logger.quiet libraryNames
}
}
dependencies {
implementation("log4j:log4j:1.2.17")
}
tasks.register("printArtifactNames") {
// always executed
val libraryNames = configurations.compileClasspath.get().map { it.name }
doLast {
logger.quiet(libraryNames.toString())
}
}
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.
dependencies {
implementation 'log4j:log4j:1.2.17'
}
tasks.register('printArtifactNames') {
doLast {
def libraryNames = configurations.compileClasspath.collect { it.name }
logger.quiet libraryNames
}
}
dependencies {
implementation("log4j:log4j:1.2.17")
}
tasks.register("printArtifactNames") {
doLast {
val libraryNames = configurations.compileClasspath.get().map { it.name }
logger.quiet(libraryNames.toString())
}
}
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:
-
Explicitly depending on a task from another project via Task.dependsOn(java.lang.Object...).
-
Setting property values or calling methods on domain objects from another project.
-
Executing another portion of the build with GradleBuild.
-
Declaring unnecessary project dependencies.
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.