| name | gradle |
| description | Use when working with build.gradle.kts, settings.gradle.kts, custom tasks, or Gradle plugins for build configuration and performance. |
| allowed-tools | Read, Edit, Write, Glob, Grep, Bash |
| metadata | {"author":"profiletailors","version":"1.0","source":"https://github.com/liutikas/gradle-best-practices"} |
Gradle Best Practices Skill
Conventions for writing efficient, maintainable, and cacheable Gradle builds.
When to Use
- Creating or modifying
build.gradle.kts or settings.gradle.kts
- Writing custom Gradle tasks or plugins
- Configuring dependencies and version catalogs
- Optimizing build performance and cacheability
- Setting up multi-module projects
Critical Patterns
1. Use Latest Versions
ALWAYS use the latest Gradle and plugin versions. Benefits include performance improvements, bug
fixes, and new features.
distributionUrl = https\:
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
}
Tip: Set up shadow CI jobs to test against upcoming Gradle versions and catch regressions
early.
2. Never Use Internal APIs
Internal APIs can break in ANY release, even minor ones. If you need functionality from an
internal API:
import org.gradle.internal.something.InternalClass
3. Avoid Ordering Assumptions
Use lazy configuration, callbacks, and provider chains. Never assume plugin application order:
val javaExtension = project.extensions.getByType<JavaPluginExtension>()
pluginManager.withPlugin("java") {
val javaExtension = extensions.getByType<JavaPluginExtension>()
}
4. Avoid afterEvaluate
afterEvaluate creates subtle ordering issues that are extremely hard to debug:
afterEvaluate {
tasks.named("someTask").configure { }
}
tasks.register<MyTask>("myTask") {
inputFile.set(layout.projectDirectory.file("input.txt"))
outputFile.set(layout.buildDirectory.file("output.txt"))
}
Custom Tasks - ALWAYS Create Task Classes
NEVER use generic tasks with doFirst/doLast. Even for simple tasks, create a custom class:
tasks.register("processFiles") {
doLast {
file("input.txt").readText()
}
}
abstract class ProcessFilesTask : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val inputFile: RegularFileProperty
@get:OutputFile
abstract val outputFile: RegularFileProperty
@TaskAction
fun process() {
val content = inputFile.get().asFile.readText()
outputFile.get().asFile.writeText(content.uppercase())
}
}
tasks.register<ProcessFilesTask>("processFiles") {
inputFile.set(layout.projectDirectory.file("input.txt"))
outputFile.set(layout.buildDirectory.file("output.txt"))
}
Note: Making task and input/output properties abstract lets Gradle auto-initialize them
without project.objects factory methods.
Enable Stricter Plugin Validation
For plugin projects, enable strict validation:
plugins {
`java-gradle-plugin`
}
tasks.withType<ValidatePlugins>().configureEach {
failOnWarning.set(true)
enableStricterValidation.set(true)
}
Dependencies
Keep Dependencies Clustered
Group dependencies by configuration for readability:
dependencies {
implementation(libs.spring.boot.starter.webflux)
implementation(libs.kotlin.coroutines.reactor)
testImplementation(libs.kotest.runner.junit5)
testImplementation(libs.mockk)
integrationTestImplementation(libs.testcontainers.postgresql)
}
Use Appropriate Configurations
Prefer implementation over api. Add dependencies where you use them:
api(libs.jackson.core)
implementation(libs.jackson.core)
Use Version Catalogs
Centralize version management in gradle/libs.versions.toml:
Note: The example below is illustrative. Actual versions and key names may differ from
gradle/libs.versions.toml in this project (e.g., springBoot vs spring-boot).
[versions]
kotlin = "2.2.21"
springBoot = "4.0.1"
kotest = "5.9.1"
[libraries]
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
spring-boot-starter-webflux = { module = "org.springframework.boot:spring-boot-starter-webflux", version.ref = "springBoot" }
kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" }
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
spring-boot = { id = "org.springframework.boot", version.ref = "springBoot" }
dependencies {
implementation(libs.spring.boot.starter.webflux)
testImplementation(libs.kotest.runner.junit5)
}
Laziness - Configuration Phase Performance
No Expensive Computations in Configuration
Configuration phase runs for EVERY build. Keep it fast:
val gitSha = "git rev-parse HEAD".execute()
abstract class GitInfoTask : DefaultTask() {
@get:OutputFile
abstract val outputFile: RegularFileProperty
@TaskAction
fun generate() {
val sha = "git rev-parse HEAD".execute()
outputFile.get().asFile.writeText(sha)
}
}
Use register Instead of create
create eagerly initializes tasks; register is lazy:
tasks.create<MyTask>("myTask") {
}
tasks.register<MyTask>("myTask") {
}
Use configureEach Instead of all
all eagerly initializes all elements; configureEach is lazy:
tasks.all {
if (this is Test) {
useJUnitPlatform()
}
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
Never Call get() Outside Task Actions
Calling get() defeats the purpose of lazy evaluation:
val inputPath = inputFile.get().asFile.absolutePath
val inputPath = inputFile.map { it.asFile.absolutePath }
@TaskAction
fun execute() {
val path = inputFile.get().asFile.absolutePath
}
Cacheability
Make Tasks Cacheable by Default
Gradle defaults to NOT caching. Explicitly enable caching:
@CacheableTask
abstract class ProcessFilesTask : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val inputFile: RegularFileProperty
@get:OutputFile
abstract val outputFile: RegularFileProperty
@TaskAction
fun process() {
}
}
Exceptions - Don't cache these:
| Task Type | Reason |
|---|
| Copy/Package/Zip | Faster to re-run locally than download from cache |
| Unpack/Extract | Same - re-running is cheaper than cache overhead |
| Non-stable inputs | Tasks using git SHA, timestamps get no cache hits |
Annotate All Inputs and Outputs
Without proper annotations, Gradle can't track changes:
abstract class MyTask : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val configFile: RegularFileProperty
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val sourceFiles: ConfigurableFileCollection
@get:Input
abstract val version: Property<String>
@get:OutputFile
abstract val outputFile: RegularFileProperty
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@get:Internal
abstract val logger: Property<Logger>
}
Path Sensitivity - Prefer NONE
Default absolute path sensitivity causes unnecessary cache misses:
@get:InputFile
abstract val inputFile: RegularFileProperty
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val inputFile: RegularFileProperty
No Overlapping Outputs
Two tasks sharing output locations causes constant cache invalidation:
tasks.register<ProcessTask>("processA") {
outputDir.set(layout.buildDirectory.dir("processed"))
}
tasks.register<ProcessTask>("processB") {
outputDir.set(layout.buildDirectory.dir("processed"))
}
tasks.register<ProcessTask>("processA") {
outputDir.set(layout.buildDirectory.dir("processed/a"))
}
tasks.register<ProcessTask>("processB") {
outputDir.set(layout.buildDirectory.dir("processed/b"))
}
Make Outputs Deterministic
Non-deterministic outputs break caching:
@TaskAction
fun process() {
val files = inputDir.get().asFile.listFiles()
val files = inputDir.get().asFile.listFiles()?.sortedBy { it.name }
}
Don't Use upToDateWhen
This API predates proper input/output handling:
tasks.named("myTask") {
outputs.upToDateWhen { false }
}
@CacheableTask
abstract class MyTask : DefaultTask() {
@get:Input
abstract val version: Property<String>
}
Configuration Cache
Never Access Project in Task Actions
Breaks configuration cache and will be deprecated:
abstract class MyTask : DefaultTask() {
@TaskAction
fun execute() {
val name = project.name
}
@get:Input
abstract val projectName: Property<String>
@TaskAction
fun execute() {
val name = projectName.get()
}
}
tasks.register<MyTask>("myTask") {
projectName.set(project.name)
}
Never Access Other Project's Instance
Cross-project configuration is fragile and breaks isolation:
project(":other-module").tasks.named("build")
dependencies {
implementation(project(":other-module"))
}
Plugin Public APIs (DSL)
Use Extensions for Public API
Don't use Gradle/system properties for plugin configuration:
val apiKey = project.findProperty("myPlugin.apiKey") as String?
abstract class MyPluginExtension {
abstract val apiKey: Property<String>
abstract val features: NamedDomainObjectContainer<Feature>
}
val extension = project.extensions.create<MyPluginExtension>("myPlugin")
myPlugin {
apiKey.set("secret")
features {
register("featureA") {
enabled.set(true)
}
}
}
Use Action<T>, Not Kotlin Lambdas
Gradle enhances bytecode for Action<T> to provide better DSL experience:
fun configure(block: (Config) -> Unit)
fun configure(action: Action<Config>)
Use Domain Object Containers, Not Lists
Containers enable enhanced DSL support:
abstract class MyExtension {
val features: MutableList<Feature> = mutableListOf()
}
abstract class MyExtension {
abstract val features: NamedDomainObjectContainer<Feature>
}
myPlugin {
features {
register("featureA") { }
register("featureB") { }
}
}
Testing
Run Integration Tests with --warning-mode=fail
Catch deprecated API usage early:
tasks.withType<Test>().configureEach {
systemProperty("gradle.warning.mode", "fail")
}
GradleRunner.create()
.withProjectDir(testProjectDir)
.withArguments("build", "--warning-mode=fail")
.build()
Anti-Patterns
| Anti-Pattern | Why It's Bad | Alternative |
|---|
| Using internal APIs | Can break in any release | Copy code or find public APIs |
afterEvaluate | Ordering nightmares | Use Provider/Property |
doFirst/doLast on ad-hoc tasks | No caching, no input/output tracking | Create custom task classes |
tasks.create | Eager initialization | tasks.register |
tasks.all | Eagerly initializes all tasks | tasks.configureEach |
provider.get() in configuration | Breaks lazy evaluation | Use map/flatMap |
Accessing project in task action | Breaks configuration cache | Declare explicit @Input properties |
| Cross-project configuration | Fragile, breaks isolation | Use dependencies |
| Overlapping task outputs | Cache invalidation | Unique output paths per task |
| Kotlin lambdas in DSL | Loses Gradle bytecode enhancement | Use Action<T> |
| Lists in extensions | No DSL enhancement | Use NamedDomainObjectContainer |
outputs.upToDateWhen | Legacy API, bypasses proper input/output system | Use proper annotations |
Commands
./gradlew wrapper --gradle-version=9.2.1
./gradlew build --warning-mode=fail
./gradlew buildHealth
./gradlew myTask --dry-run
./gradlew build --configuration-cache
./gradlew build --scan
Resources