AgentSkillsCN

gradle

>

SKILL.md
--- frontmatter
name: gradle
description: >
    Gradle best practices for build configuration, custom plugins, and performance.
    Trigger: When working with build.gradle.kts, settings.gradle.kts, custom tasks, or Gradle plugins.
allowed-tools: Read, Edit, Write, Glob, Grep, Bash
metadata:
    author: @dallay
    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.

kotlin
// 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:

kotlin
// ❌ 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:

kotlin
// ❌ 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:

kotlin
// ❌ 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:

kotlin
// ❌ 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 abstract lets Gradle auto-initialize them without project.objects factory methods.

Enable Stricter Plugin Validation

For plugin projects, enable strict validation:

kotlin
// 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:

kotlin
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:

kotlin
// ❌ 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.toml in this project (e.g., springBoot vs spring-boot).

toml
# 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" }
kotlin
// 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:

kotlin
// ❌ 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:

kotlin
// ❌ 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:

kotlin
// ❌ 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:

kotlin
// ❌ 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:

kotlin
@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 TypeReason
Copy/Package/ZipFaster to re-run locally than download from cache
Unpack/ExtractSame - re-running is cheaper than cache overhead
Non-stable inputsTasks using git SHA, timestamps get no cache hits

Annotate All Inputs and Outputs

Without proper annotations, Gradle can't track changes:

kotlin
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:

kotlin
// ❌ 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:

kotlin
// ❌ 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:

kotlin
@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:

kotlin
// ❌ 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:

kotlin
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:

kotlin
// ❌ 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:

kotlin
// ❌ 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:

kotlin
// ❌ 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:

kotlin
// ❌ 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:

kotlin
// 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-PatternWhy It's BadAlternative
Using internal APIsCan break in any releaseCopy code or find public APIs
afterEvaluateOrdering nightmaresUse Provider/Property
doFirst/doLast on ad-hoc tasksNo caching, no input/output trackingCreate custom task classes
tasks.createEager initializationtasks.register
tasks.allEagerly initializes all taskstasks.configureEach
provider.get() in configurationBreaks lazy evaluationUse map/flatMap
Accessing project in task actionBreaks configuration cacheDeclare explicit @Input properties
Cross-project configurationFragile, breaks isolationUse dependencies
Overlapping task outputsCache invalidationUnique output paths per task
Kotlin lambdas in DSLLoses Gradle bytecode enhancementUse Action<T>
Lists in extensionsNo DSL enhancementUse NamedDomainObjectContainer
outputs.upToDateWhenLegacy API, bypasses proper input/output systemUse proper annotations

Commands

bash
# 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

Resources