Gradle Best Practices Skill
Conventions for writing efficient, maintainable, and cacheable Gradle builds.
When to Use
- •Creating or modifying
build.gradle.ktsorsettings.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.
// gradle/wrapper/gradle-wrapper.properties
distributionUrl = https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
// settings.gradle.kts - use shadow jobs to test upcoming versions
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:
// ❌ NEVER do this import org.gradle.internal.something.InternalClass // BREAKS! // ✅ Copy relevant bits to your codebase or find public alternatives // Internal APIs are "fair game" for breaking changes
3. Avoid Ordering Assumptions
Use lazy configuration, callbacks, and provider chains. Never assume plugin application order:
// ❌ WRONG: Assumes ordering
val javaExtension = project.extensions.getByType<JavaPluginExtension>()
// ✅ CORRECT: React to plugin being applied
pluginManager.withPlugin("java") {
val javaExtension = extensions.getByType<JavaPluginExtension>()
// Configure safely
}
4. Avoid afterEvaluate
afterEvaluate creates subtle ordering issues that are extremely hard to debug:
// ❌ NEVER do this
afterEvaluate {
// This introduces ordering nightmares
tasks.named("someTask").configure { /* ... */ }
}
// ✅ Use Provider/Property for lazy evaluation
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:
// ❌ WRONG: Generic task with doLast
tasks.register("processFiles") {
doLast {
// No inputs, outputs, or cacheability!
file("input.txt").readText()
}
}
// ✅ CORRECT: Custom task class with proper annotations
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())
}
}
// Register the task
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
abstractlets Gradle auto-initialize them withoutproject.objectsfactory methods.
Enable Stricter Plugin Validation
For plugin projects, enable strict validation:
// build.gradle.kts (in plugin project)
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 {
// Production dependencies
implementation(libs.spring.boot.starter.webflux)
implementation(libs.kotlin.coroutines.reactor)
// Test dependencies
testImplementation(libs.kotest.runner.junit5)
testImplementation(libs.mockk)
// Integration test dependencies
integrationTestImplementation(libs.testcontainers.postgresql)
}
Use Appropriate Configurations
Prefer implementation over api. Add dependencies where you use them:
// ❌ WRONG: api when implementation suffices api(libs.jackson.core) // Unnecessarily exposes to consumers // ✅ CORRECT: implementation hides transitive dependencies implementation(libs.jackson.core) // Use dependency-analysis plugin to maintain clean lists // https://github.com/autonomousapps/dependency-analysis-android-gradle-plugin
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.tomlin this project (e.g.,springBootvsspring-boot).
# gradle/libs.versions.toml
[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" }
// build.gradle.kts - use the catalog
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:
// ❌ WRONG: Expensive operation in configuration phase
val gitSha = "git rev-parse HEAD".execute() // Runs on every build!
// ✅ CORRECT: Defer to task action
abstract class GitInfoTask : DefaultTask() {
@get:OutputFile
abstract val outputFile: RegularFileProperty
@TaskAction
fun generate() {
val sha = "git rev-parse HEAD".execute() // Runs only when task executes
outputFile.get().asFile.writeText(sha)
}
}
Use register Instead of create
create eagerly initializes tasks; register is lazy:
// ❌ WRONG: Eager initialization
tasks.create<MyTask>("myTask") {
// This runs immediately during configuration
}
// ✅ CORRECT: Lazy registration
tasks.register<MyTask>("myTask") {
// This runs only if the task is needed
}
Use configureEach Instead of all
all eagerly initializes all elements; configureEach is lazy:
// ❌ WRONG: Forces all tasks to initialize
tasks.all {
if (this is Test) {
useJUnitPlatform()
}
}
// ✅ CORRECT: Only configures when task is needed
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
Never Call get() Outside Task Actions
Calling get() defeats the purpose of lazy evaluation:
// ❌ WRONG: Eager evaluation breaks ordering
val inputPath = inputFile.get().asFile.absolutePath // Too early!
// ✅ CORRECT: Use map/flatMap for transformations
val inputPath = inputFile.map { it.asFile.absolutePath }
// ✅ CORRECT: Only call get() inside task actions
@TaskAction
fun execute() {
val path = inputFile.get().asFile.absolutePath // OK here
}
Cacheability
Make Tasks Cacheable by Default
Gradle defaults to NOT caching. Explicitly enable caching:
@CacheableTask // Enable caching
abstract class ProcessFilesTask : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE) // Content-only sensitivity
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() {
// File inputs
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val configFile: RegularFileProperty
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val sourceFiles: ConfigurableFileCollection
// Simple inputs
@get:Input
abstract val version: Property<String>
// Outputs
@get:OutputFile
abstract val outputFile: RegularFileProperty
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
// Non-input properties
@get:Internal
abstract val logger: Property<Logger>
}
Path Sensitivity - Prefer NONE
Default absolute path sensitivity causes unnecessary cache misses:
// ❌ DEFAULT: Absolute path sensitive (bad for caching) @get:InputFile abstract val inputFile: RegularFileProperty // ✅ CORRECT: Content-only (best for caching) @get:InputFile @get:PathSensitive(PathSensitivity.NONE) abstract val inputFile: RegularFileProperty // Other options: // PathSensitivity.NAME_ONLY - file name matters, not path // PathSensitivity.RELATIVE - relative path from project root matters // @Classpath - for JVM classpath entries
No Overlapping Outputs
Two tasks sharing output locations causes constant cache invalidation:
// ❌ WRONG: Shared output directory
tasks.register<ProcessTask>("processA") {
outputDir.set(layout.buildDirectory.dir("processed")) // Collision!
}
tasks.register<ProcessTask>("processB") {
outputDir.set(layout.buildDirectory.dir("processed")) // Collision!
}
// ✅ CORRECT: Unique outputs per task
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() {
// ❌ WRONG: Non-deterministic ordering
val files = inputDir.get().asFile.listFiles() // Order may vary!
// ✅ CORRECT: Sort for deterministic output
val files = inputDir.get().asFile.listFiles()?.sortedBy { it.name }
}
Don't Use upToDateWhen
This API predates proper input/output handling:
// ❌ AVOID: Legacy API
tasks.named("myTask") {
outputs.upToDateWhen { false } // Only acceptable use: force re-run
}
// ✅ CORRECT: Use proper input/output annotations
@CacheableTask
abstract class MyTask : DefaultTask() {
@get:Input
abstract val version: Property<String> // Proper tracking
}
Configuration Cache
Never Access Project in Task Actions
Breaks configuration cache and will be deprecated:
abstract class MyTask : DefaultTask() {
// ❌ WRONG: Accessing project in action
@TaskAction
fun execute() {
val name = project.name // BREAKS CONFIGURATION CACHE!
}
// ✅ CORRECT: Declare explicit inputs
@get:Input
abstract val projectName: Property<String>
@TaskAction
fun execute() {
val name = projectName.get() // OK!
}
}
// When registering:
tasks.register<MyTask>("myTask") {
projectName.set(project.name) // Set during configuration
}
Never Access Other Project's Instance
Cross-project configuration is fragile and breaks isolation:
// ❌ WRONG: Cross-project configuration
project(":other-module").tasks.named("build") // FRAGILE!
// ✅ CORRECT: Declare dependencies between projects
dependencies {
implementation(project(":other-module"))
}
Plugin Public APIs (DSL)
Use Extensions for Public API
Don't use Gradle/system properties for plugin configuration:
// ❌ WRONG: Using properties
val apiKey = project.findProperty("myPlugin.apiKey") as String?
// ✅ CORRECT: Create an extension
abstract class MyPluginExtension {
abstract val apiKey: Property<String>
abstract val features: NamedDomainObjectContainer<Feature>
}
// In plugin:
val extension = project.extensions.create<MyPluginExtension>("myPlugin")
// Usage in build.gradle.kts:
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:
// ❌ WRONG: Kotlin lambda fun configure(block: (Config) -> Unit) // ✅ CORRECT: Gradle Action fun configure(action: Action<Config>)
Use Domain Object Containers, Not Lists
Containers enable enhanced DSL support:
// ❌ WRONG: Plain list
abstract class MyExtension {
val features: MutableList<Feature> = mutableListOf()
}
// ✅ CORRECT: Domain object container
abstract class MyExtension {
abstract val features: NamedDomainObjectContainer<Feature>
}
// Enables DSL:
myPlugin {
features {
register("featureA") { /* configure */ }
register("featureB") { /* configure */ }
}
}
Testing
Run Integration Tests with --warning-mode=fail
Catch deprecated API usage early:
// In plugin test setup
tasks.withType<Test>().configureEach {
// Make warnings fail the build
systemProperty("gradle.warning.mode", "fail")
}
// Or in GradleRunner for integration tests
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
# Run with latest Gradle wrapper ./gradlew wrapper --gradle-version=9.2.1 # Check for deprecated API usage ./gradlew build --warning-mode=fail # Analyze dependencies ./gradlew buildHealth # requires dependency-analysis plugin # Show task dependencies ./gradlew myTask --dry-run # Debug configuration cache ./gradlew build --configuration-cache # Profile build ./gradlew build --scan