Claude-code-setup standards-gradle
Gradle build tool standards focusing on Kotlin DSL. Covers project configuration, dependency management, and custom plugin/task development with Gradle 9 LTS.
git clone https://github.com/b33eep/claude-code-setup
T=$(mktemp -d) && git clone --depth=1 https://github.com/b33eep/claude-code-setup "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/standards-gradle" ~/.claude/skills/b33eep-claude-code-setup-standards-gradle && rm -rf "$T"
skills/standards-gradle/SKILL.mdGradle Coding Standards (Gradle 9 LTS)
This skill provides comprehensive guidance for Gradle build configuration using Kotlin DSL (
.gradle.kts). It covers both everyday project configuration and advanced plugin/task development patterns based on Gradle 9 LTS.
Core Principles
- Declarative over Imperative: Prefer declarative configuration that describes what you want, not how to achieve it
- Type-Safe Configuration: Use Kotlin DSL for type safety and IDE support
- Lazy Configuration: Use Providers API to defer configuration until needed
- Build Cache Friendly: Write tasks that support build caching for faster builds
- Configuration Cache Compatible: Ensure build scripts work with configuration cache for optimal performance
Section 1: Project Configuration
This section covers the common scenarios developers encounter when configuring Gradle projects: setting up build scripts, managing dependencies, applying plugins, and structuring multi-module projects.
Build Script Basics
build.gradle.kts Structure
Organize your build script in a consistent, readable order:
// 1. Plugin declarations (always first) plugins { java application id("com.github.johnrengelman.shadow") version "8.1.1" } // 2. Project properties and versioning group = "com.example" version = "1.0.0" // 3. Repositories repositories { mavenCentral() } // 4. Dependencies dependencies { implementation("com.google.guava:guava:33.0.0-jre") testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") } // 5. Java/Kotlin configuration java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } // 6. Task configuration tasks { test { useJUnitPlatform() } jar { manifest { attributes("Main-Class" to "com.example.Main") } } }
settings.gradle.kts Basics
// Root project name rootProject.name = "my-project" // Enable Gradle version catalogs (Gradle 9+) enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") // Include subprojects include("app") include("lib") include("common") // Optional: Customize subproject location project(":app").projectDir = file("applications/app")
Repository Configuration
repositories { // GOOD: Standard repositories first mavenCentral() // GOOD: Google repository for Android/Google libraries google() // GOOD: Custom repository with HTTPS maven { name = "CompanyRepo" url = uri("https://repo.company.com/maven") credentials { username = providers.gradleProperty("repoUser").orNull password = providers.gradleProperty("repoPassword").orNull } } } // BAD: Using HTTP instead of HTTPS (security risk) // maven { url = uri("http://insecure-repo.com/maven") } // BAD: Exposing credentials in build script // maven { // url = uri("https://repo.company.com/maven") // credentials { // username = "hardcoded-user" // Never do this! // password = "hardcoded-pass" // Never do this! // } // }
Script Organization Best Practices
// GOOD: Use extra properties for shared values val mockitoVersion by extra("5.10.0") val junitVersion by extra("5.10.2") dependencies { testImplementation("org.mockito:mockito-core:$mockitoVersion") testImplementation("org.junit.jupiter:junit-jupiter:$junitVersion") } // GOOD: Extract complex configuration to functions fun configureJavaToolchain() { java { toolchain { languageVersion = JavaLanguageVersion.of(21) vendor = JvmVendorSpec.ADOPTIUM } } } // Apply configuration configureJavaToolchain()
Gradle Build Phases
Understanding Gradle's build phases is essential for writing efficient build scripts and understanding when your code executes.
The Three Build Phases
Every Gradle build runs through three distinct phases in order:
- Initialization Phase - Determines which projects participate in the build
- Configuration Phase - Configures all projects and builds the task graph
- Execution Phase - Executes the selected tasks
Understanding these phases helps you:
- Write faster builds (keep configuration phase light)
- Understand lazy evaluation and Provider API
- Make configuration cache work correctly
- Debug build script behavior
1. Initialization Phase
Purpose: Determine project structure and which projects participate in the build.
What runs:
settings.gradle.kts files
What happens:
- Gradle locates and reads
settings.gradle.kts - Determines root project and subprojects
- Creates
instances for each projectProject
Example:
// settings.gradle.kts (runs during initialization) rootProject.name = "my-project" println("Initialization phase") // Prints during initialization include("app") include("lib") include("common") // Optional: Customize subproject directories project(":app").projectDir = file("applications/app")
Duration: Very fast (typically < 100ms)
Key Point: You cannot access
Project objects yet - they're being created.
2. Configuration Phase
Purpose: Configure all tasks and build the task execution graph.
What runs: All
build.gradle.kts files for participating projects
What happens:
- Applies plugins
- Evaluates all top-level code in build scripts
- Configures tasks (but doesn't execute them)
- Builds task dependency graph
- Prepares for execution
Example:
// build.gradle.kts (runs during configuration) plugins { java // Runs during configuration } version = "1.0.0" // Runs during configuration println("Configuration phase") // Runs during configuration tasks.register("myTask") { group = "custom" // Runs during configuration description = "Example task" // Runs during configuration println("Task configuration") // Runs during configuration doLast { println("Task execution") // Does NOT run during configuration! } } // This runs during configuration val projectVersion = version println("Project version: $projectVersion") // BAD: Expensive work during configuration // val allFiles = File("src").walkTopDown().toList() // Slows every build! // GOOD: Use providers for lazy evaluation val sourceFiles: Provider<FileTree> = providers.provider { fileTree("src") // Only evaluated when needed }
Duration: Can be slow if not careful (seconds to minutes for large projects)
Key Point: Configuration runs on every build, even if no tasks execute. Keep it fast!
3. Execution Phase
Purpose: Execute the selected tasks in dependency order.
What runs: Task actions (
doFirst, doLast, @TaskAction)
What happens:
- Tasks execute in correct dependency order
- Task inputs are read
- Task outputs are generated
- Build artifacts are created
Example:
tasks.register("myTask") { // Configuration phase group = "custom" doFirst { // Execution phase - runs first println("Starting task") } doLast { // Execution phase - runs last println("Task completed") } } // Abstract task with @TaskAction abstract class BuildTask : DefaultTask() { @get:InputDirectory abstract val sourceDir: DirectoryProperty @get:OutputDirectory abstract val outputDir: DirectoryProperty @TaskAction // Execution phase fun build() { println("Building...") // Actual work happens here } }
Duration: Depends on what tasks do (compile, test, package, etc.)
Key Point: Only requested tasks (and their dependencies) execute.
When Code Runs - Quick Reference
| Code Location | Phase | Example |
|---|---|---|
(top-level) | Initialization | |
(top-level) | Configuration | |
block | Configuration | |
block | Configuration | |
outer block | Configuration | |
inner block | Configuration | |
| Extension configuration blocks | Configuration | |
| Execution | |
| Execution | |
method | Execution | |
| Provider.get() in doLast | Execution | |
Common Mistakes and Anti-Patterns
// ❌ BAD: Expensive I/O during configuration tasks.register("badTask") { val files = File("src").listFiles() // I/O during configuration - runs every build! println("Found ${files?.size} files") doLast { println("Processing ${files?.size} files") } } // ✅ GOOD: Defer work to execution tasks.register("goodTask") { doLast { val files = File("src").listFiles() // I/O during execution - only when task runs println("Found ${files?.size} files") println("Processing ${files.size} files") } } // ❌ BAD: Accessing task outputs during configuration tasks.register("badConsumer") { val compileOutput = tasks.named("compileJava").get().outputs.files // Not ready yet! doLast { println(compileOutput) } } // ✅ GOOD: Use providers to defer access tasks.register("goodConsumer") { val compileOutput = tasks.named("compileJava").map { it.outputs.files } doLast { println(compileOutput.get()) // Resolved during execution } } // ❌ BAD: Network calls during configuration tasks.register("badFetch") { val response = URL("https://api.example.com/version").readText() // Slows every build! doLast { println("Version: $response") } } // ✅ GOOD: Use providers for network calls tasks.register("goodFetch") { val response: Provider<String> = providers.provider { URL("https://api.example.com/version").readText() } doLast { println("Version: ${response.get()}") // Only called during execution } } // ❌ BAD: Calling .get() on providers during configuration tasks.register("badProvider") { val version = providers.gradleProperty("version").get() // Eager evaluation! doLast { println("Version: $version") } } // ✅ GOOD: Defer .get() until execution tasks.register("goodProvider") { val version = providers.gradleProperty("version") // Lazy - not evaluated yet doLast { println("Version: ${version.get()}") // Evaluated here } } // ❌ BAD: Mutating shared state during configuration var counter = 0 // Global mutable state tasks.register("bad1") { counter++ // Modifies global state during configuration doLast { println("Counter: $counter") } } tasks.register("bad2") { counter++ // Order-dependent! doLast { println("Counter: $counter") } } // ✅ GOOD: Use build services or task outputs for shared state
Why Build Phases Matter
1. Build Performance
Configuration phase runs on every build:
./gradlew tasks # Configuration runs ./gradlew clean # Configuration runs ./gradlew build # Configuration runs ./gradlew --stop # Configuration runs
Slow configuration = slow every command, even
./gradlew tasks!
2. Configuration Cache
Configuration cache stores the result of configuration phase:
# First run: Configuration + execution ./gradlew build --configuration-cache # Configuration phase: 5 seconds # Execution phase: 30 seconds # Second run: Execution only ./gradlew clean build --configuration-cache # Configuration phase: 0 seconds (reused from cache!) # Execution phase: 30 seconds
Benefits:
- Up to 90% faster builds (skip configuration entirely)
- Especially valuable for large projects
Requirements:
- Use Provider API (lazy evaluation)
- No mutable shared state
- No accessing
during executionproject - Serializable configuration
3. Up-to-Date Checks
Tasks are up-to-date when:
- Inputs haven't changed
- Outputs exist and are valid
Input/output annotations are evaluated during:
- Configuration: Gradle determines task inputs/outputs
- Execution: Gradle checks if task needs to run
Proper annotations enable:
- Incremental builds
- Build cache
andFROM-CACHE
optimizationsUP-TO-DATE
Best Practices for Build Phases
Do:
- ✅ Keep configuration phase fast (< 1 second per project ideal)
- ✅ Use
for lazy task creationtasks.register() - ✅ Use Provider API for lazy evaluation
- ✅ Defer expensive work to execution phase
- ✅ Use
/@Input
annotations properly@Output - ✅ Test with
to catch issues--configuration-cache
Don't:
- ❌ Perform I/O during configuration (file scanning, network calls)
- ❌ Use
(eager - prefertasks.create()
)register() - ❌ Call
on providers during configuration.get() - ❌ Access task outputs during configuration
- ❌ Mutate global/shared state during configuration
- ❌ Use
references in task actionsproject
Debugging Build Phases
# See configuration time breakdown ./gradlew build --profile # Open: build/reports/profile/profile-<timestamp>.html # Measure configuration time ./gradlew build --configuration-cache --configuration-cache-problems=warn # See what runs during configuration ./gradlew build --info | grep "Configuration" # Test configuration cache compatibility ./gradlew build --configuration-cache ./gradlew clean build --configuration-cache # Should show "Reusing configuration cache"
Example: Full Build Lifecycle
// settings.gradle.kts println("1. Initialization phase: settings.gradle.kts") rootProject.name = "lifecycle-demo" // build.gradle.kts println("2. Configuration phase: build.gradle.kts top-level") plugins { java println("3. Configuration phase: plugins block") } println("4. Configuration phase: after plugins") tasks.register("demo") { println("5. Configuration phase: task configuration") group = "demo" description = "Demonstrates build phases" doFirst { println("7. Execution phase: doFirst") } doLast { println("8. Execution phase: doLast") } } println("6. Configuration phase: after task registration") // When you run: ./gradlew demo // Output order: // 1. Initialization phase: settings.gradle.kts // 2. Configuration phase: build.gradle.kts top-level // 3. Configuration phase: plugins block // 4. Configuration phase: after plugins // 5. Configuration phase: task configuration // 6. Configuration phase: after task registration // 7. Execution phase: doFirst // 8. Execution phase: doLast
Dependency Management
Dependency Configurations
dependencies { // GOOD: implementation - for internal dependencies (not exposed to consumers) implementation("com.google.guava:guava:33.0.0-jre") // GOOD: api - for dependencies exposed to consumers (libraries only) // Only available with java-library plugin api("org.apache.commons:commons-lang3:3.14.0") // GOOD: compileOnly - compile-time only (not packaged) compileOnly("org.projectlombok:lombok:1.18.30") // GOOD: runtimeOnly - runtime only (not on compile classpath) runtimeOnly("com.h2database:h2:2.2.224") // GOOD: testImplementation - for test code only testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") testImplementation("org.mockito:mockito-core:5.10.0") // GOOD: testRuntimeOnly - test runtime only testRuntimeOnly("org.junit.platform:junit-platform-launcher") } // BAD: Using 'compile' (deprecated in Gradle 7+) // dependencies { // compile("some:library:1.0") // Use 'implementation' instead // } // BAD: Using 'runtime' (deprecated in Gradle 7+) // dependencies { // runtime("some:library:1.0") // Use 'runtimeOnly' instead // }
Version Catalogs (Modern Gradle Approach)
gradle/libs.versions.toml:
[versions] guava = "33.0.0-jre" junit = "5.10.2" mockito = "5.10.0" kotlin = "2.0.0" [libraries] guava = { module = "com.google.guava:guava", version.ref = "guava" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" } [bundles] testing = ["junit-jupiter", "mockito-core"] [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } shadow = { id = "com.github.johnrengelman.shadow", version = "8.1.1" }
build.gradle.kts:
plugins { alias(libs.plugins.kotlin.jvm) } dependencies { // GOOD: Type-safe accessors from version catalog implementation(libs.guava) testImplementation(libs.bundles.testing) testRuntimeOnly(libs.junit.platform.launcher) } // Benefits: // - Centralized version management // - Type-safe accessors with IDE completion // - Easy to share across multi-module projects // - Prevents version conflicts
Dependency Constraints
dependencies { implementation("com.example:library:1.0") // GOOD: Force specific version to resolve conflicts constraints { implementation("org.slf4j:slf4j-api:2.0.9") { because("Earlier versions have security vulnerabilities") } } // GOOD: Align versions across dependency group constraints { implementation("org.springframework.boot:spring-boot-starter-web:3.2.0") implementation("org.springframework.boot:spring-boot-starter-data-jpa:3.2.0") } }
Platform/BOM Dependencies
dependencies { // GOOD: Import BOM (Bill of Materials) for version alignment implementation(platform("org.springframework.boot:spring-boot-dependencies:3.2.0")) // Now you can omit versions - they come from the BOM implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-data-jpa") // GOOD: For testing, use testImplementation(platform(...)) testImplementation(platform("org.junit:junit-bom:5.10.2")) testImplementation("org.junit.jupiter:junit-jupiter") }
Excluding Transitive Dependencies
dependencies { // GOOD: Exclude specific transitive dependency implementation("com.example:library:1.0") { exclude(group = "commons-logging", module = "commons-logging") } // GOOD: Exclude all transitive dependencies (rare case) implementation("com.example:utility:1.0") { isTransitive = false } // Replace excluded dependency with alternative implementation("org.slf4j:jcl-over-slf4j:2.0.9") } // GOOD: Exclude globally (affects all dependencies) configurations.all { exclude(group = "commons-logging", module = "commons-logging") }
Dependency Notation
dependencies { // GOOD: String notation (most common) implementation("com.google.guava:guava:33.0.0-jre") // GOOD: Map notation (when you need more control) implementation(group = "com.google.guava", name = "guava", version = "33.0.0-jre") // GOOD: With classifier implementation("net.java.dev.jna:jna:5.13.0:jpms") // GOOD: Local file dependency implementation(files("libs/custom-library.jar")) // GOOD: File tree dependency implementation(fileTree("libs") { include("*.jar") }) // GOOD: Project dependency (multi-module) implementation(project(":common")) }
Plugin Configuration
Plugin Application
plugins { // GOOD: Core plugins (no version needed) java application // GOOD: External plugin with version id("com.github.johnrengelman.shadow") version "8.1.1" // GOOD: Kotlin plugin kotlin("jvm") version "2.0.0" // GOOD: Apply false (for root project in multi-module) id("org.springframework.boot") version "3.2.0" apply false } // BAD: Old apply() syntax (avoid in new code) // apply(plugin = "java") // Use plugins {} block instead
Using Version Catalogs with Plugins
// gradle/libs.versions.toml // [plugins] // kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version = "2.0.0" } // shadow = { id = "com.github.johnrengelman.shadow", version = "8.1.1" } plugins { // GOOD: Type-safe plugin declaration from catalog alias(libs.plugins.kotlin.jvm) alias(libs.plugins.shadow) }
Common Plugins
Java Plugin:
plugins { java } java { // GOOD: Use Java toolchain (modern approach) toolchain { languageVersion = JavaLanguageVersion.of(21) vendor = JvmVendorSpec.ADOPTIUM } // GOOD: Configure compatibility (legacy approach) // sourceCompatibility = JavaVersion.VERSION_21 // targetCompatibility = JavaVersion.VERSION_21 // GOOD: Enable automatic module name for JPMS modularity.inferModulePath = true // GOOD: Generate sources and javadoc JARs withSourcesJar() withJavadocJar() }
Kotlin JVM Plugin:
plugins { kotlin("jvm") version "2.0.0" } kotlin { // GOOD: Set JVM target jvmToolchain(21) // GOOD: Enable explicit API mode (libraries) explicitApi() // GOOD: Compiler options compilerOptions { freeCompilerArgs.add("-Xjsr305=strict") allWarningsAsErrors = true } }
Application Plugin:
plugins { application } application { // GOOD: Set main class mainClass = "com.example.Main" // GOOD: Configure application name applicationName = "my-app" // GOOD: Set default JVM args applicationDefaultJvmArgs = listOf("-Xmx512m", "-Xms256m") } // Run with: ./gradlew run // Package with: ./gradlew installDist
Java Library Plugin:
plugins { `java-library` // Note the backticks for kebab-case } dependencies { // GOOD: Use 'api' for exposed dependencies api("org.apache.commons:commons-lang3:3.14.0") // GOOD: Use 'implementation' for internal dependencies implementation("com.google.guava:guava:33.0.0-jre") } // Consumers of this library get: // - api dependencies on their compile classpath // - implementation dependencies are hidden
Configuring Plugin Extensions
plugins { java jacoco } // GOOD: Configure extension in dedicated block jacoco { toolVersion = "0.8.11" reportsDirectory = layout.buildDirectory.dir("reports/jacoco") } // GOOD: Configure task created by plugin tasks.jacocoTestReport { dependsOn(tasks.test) reports { xml.required = true html.required = true csv.required = false } } // BAD: Accessing extension before plugin is applied // jacoco { ... } // Will fail if jacoco plugin not applied // plugins { jacoco } // Plugin should come first
Conditional Plugin Application
plugins { java if (project.hasProperty("enableKotlin")) { kotlin("jvm") version "2.0.0" } } // Alternative: Apply plugin conditionally if (project.findProperty("coverage") == "true") { apply(plugin = "jacoco") }
Multi-Module Projects
Project Structure
my-project/ ├── settings.gradle.kts # Project structure definition ├── build.gradle.kts # Root build script ├── gradle/ │ └── libs.versions.toml # Shared version catalog ├── app/ │ ├── build.gradle.kts # Application module │ └── src/ ├── lib/ │ ├── build.gradle.kts # Library module │ └── src/ └── common/ ├── build.gradle.kts # Shared code module └── src/
settings.gradle.kts:
rootProject.name = "my-project" // Enable type-safe project accessors (Gradle 7+) enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") include("app") include("lib") include("common") // Optional: Nested modules include("backend:api") include("backend:service")
Root build.gradle.kts:
plugins { // GOOD: Apply plugins to all subprojects java apply false kotlin("jvm") version "2.0.0" apply false } // GOOD: Configure all projects (including root) allprojects { group = "com.example" version = "1.0.0" repositories { mavenCentral() } } // GOOD: Configure only subprojects subprojects { // Apply common configuration here }
Convention Plugins (Recommended Approach)
Convention plugins encapsulate shared configuration in a type-safe, reusable way.
buildSrc/build.gradle.kts:
plugins { `kotlin-dsl` } repositories { mavenCentral() }
buildSrc/src/main/kotlin/java-conventions.gradle.kts:
plugins { java } java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } repositories { mavenCentral() } dependencies { testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } tasks.test { useJUnitPlatform() }
app/build.gradle.kts:
plugins { id("java-conventions") // Apply convention plugin application } application { mainClass = "com.example.app.Main" } dependencies { implementation(project(":lib")) implementation(project(":common")) }
lib/build.gradle.kts:
plugins { id("java-conventions") // Apply convention plugin `java-library` } dependencies { api(project(":common")) implementation("com.google.guava:guava:33.0.0-jre") }
Cross-Module Dependencies
dependencies { // GOOD: Type-safe project accessor (with TYPESAFE_PROJECT_ACCESSORS) implementation(projects.common) implementation(projects.backend.api) // GOOD: String-based (works without feature preview) implementation(project(":common")) implementation(project(":backend:api")) // GOOD: Depend on specific configuration testImplementation(project(path = ":lib", configuration = "testFixtures")) }
Shared Configuration Patterns
Pattern 1: subprojects {} (Quick but limited)
subprojects { apply(plugin = "java") java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } dependencies { testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") } } // BAD: Hard to override, not type-safe, mixes concerns
Pattern 2: Convention Plugins (Recommended)
// buildSrc/src/main/kotlin/java-library-conventions.gradle.kts plugins { `java-library` } java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } // GOOD: Type-safe, reusable, easy to override // Modules apply with: plugins { id("java-library-conventions") }
buildSrc vs Included Builds vs Composite Builds
buildSrc (For Convention Plugins):
- Built automatically before main build
- Used for convention plugins and build logic
- Not published
- Changes require Gradle daemon restart
project/ ├── buildSrc/ │ ├── build.gradle.kts │ └── src/main/kotlin/ │ └── java-conventions.gradle.kts └── build.gradle.kts
Included Builds (For Build Logic Libraries):
- Separate Gradle project included in your build
- Can be published independently
- Changes don't require daemon restart
settings.gradle.kts:
includeBuild("build-logic") include("app") include("lib")
Composite Builds (For Multi-Repo Projects):
- Combine multiple independent Gradle builds
- Each build has its own settings.gradle.kts
settings.gradle.kts:
includeBuild("../other-project")
Dependency Management Across Modules
Using Version Catalogs (Recommended):
// gradle/libs.versions.toml (at root) [versions] guava = "33.0.0-jre" [libraries] guava = { module = "com.google.guava:guava", version.ref = "guava" } // All modules can use: implementation(libs.guava)
Platform Projects (Alternative):
// platform/build.gradle.kts plugins { `java-platform` } dependencies { constraints { api("com.google.guava:guava:33.0.0-jre") api("org.slf4j:slf4j-api:2.0.9") } } // Other modules: dependencies { implementation(platform(project(":platform"))) implementation("com.google.guava:guava") // Version from platform }
Gradle 9 Features
Gradle 9 is the latest LTS (Long-Term Support) release with significant improvements to performance, developer experience, and build reliability.
Configuration Cache (Stable in Gradle 9)
Configuration cache dramatically speeds up builds by caching the result of the configuration phase.
Enable in gradle.properties:
org.gradle.configuration-cache=true
Or via command line:
./gradlew build --configuration-cache
Benefits:
- Up to 90% faster for configuration-heavy builds
- Second builds reuse cached configuration
- Encourages better build practices
Making Your Build Compatible:
// GOOD: Use providers instead of direct property access val myProperty: Provider<String> = providers.gradleProperty("myProp") tasks.register("example") { doLast { println(myProperty.get()) // Lazy evaluation } } // BAD: Direct property access (breaks configuration cache) // val value = project.findProperty("myProp") // Evaluated at configuration time
Build Cache (Enhanced in Gradle 9)
Enable in gradle.properties:
org.gradle.caching=true
Or via command line:
./gradlew build --build-cache
Configure cache:
buildCache { local { isEnabled = true directory = file("${rootDir}/.gradle/build-cache") removeUnusedEntriesAfterDays = 30 } remote<HttpBuildCache> { isEnabled = true url = uri("https://cache.example.com/") isPush = System.getenv("CI") == "true" // Only push from CI credentials { username = providers.gradleProperty("cacheUser").orNull password = providers.gradleProperty("cachePassword").orNull } } }
Improved Test Suites API
testing { suites { val test by getting(JvmTestSuite::class) { useJUnitJupiter("5.10.2") } // GOOD: Define integration test suite val integrationTest by registering(JvmTestSuite::class) { testType = TestSuiteType.INTEGRATION_TEST dependencies { implementation(project()) implementation("org.testcontainers:junit-jupiter:1.19.3") } targets { all { testTask.configure { shouldRunAfter(test) } } } } } } // Run with: ./gradlew integrationTest
Java Toolchains (Enhanced)
java { toolchain { // GOOD: Specify vendor vendor = JvmVendorSpec.ADOPTIUM languageVersion = JavaLanguageVersion.of(21) // GOOD: Gradle auto-downloads if not available } } // GOOD: Use different toolchain for specific task tasks.register<JavaExec>("runWithJava17") { javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(17) } }
Problems API (New in Gradle 8+, Refined in 9)
Better error reporting and problem aggregation:
// Gradle automatically collects and reports problems // Your build output now shows: // - Aggregated problems // - Actionable error messages // - Problem locations with file:line references // No configuration needed - it just works better!
Isolated Projects (Experimental in Gradle 9)
Parallel configuration of subprojects for massive multi-module builds.
# gradle.properties org.gradle.unsafe.isolated-projects=true
Benefits:
- Parallel configuration of independent projects
- Reduced configuration time for large builds
- Requires strict project isolation
Deprecated Features to Avoid
// BAD: compile, runtime configurations (removed in Gradle 8+) // dependencies { // compile("some:library:1.0") // Use 'implementation' // runtime("some:library:1.0") // Use 'runtimeOnly' // } // BAD: Old task creation API (prefer register) // tasks.create("myTask") { ... } // Use tasks.register("myTask") { ... } // BAD: Convention properties (use extensions) // project.convention.plugins // Use project.extensions // BAD: Direct task execution during configuration // tasks.named("build").get().execute() // Never execute tasks during configuration
Performance Improvements in Gradle 9
- Faster dependency resolution - Up to 40% faster for large dependency graphs
- Improved incremental compilation - Better change detection for Java/Kotlin
- Enhanced file system watching - More efficient change detection
- Better daemon memory management - Reduced memory usage over time
- Optimized configuration cache - Faster serialization/deserialization
Best Practices for Gradle 9
// GOOD: Enable all performance features // gradle.properties: org.gradle.caching=true org.gradle.configuration-cache=true org.gradle.parallel=true org.gradle.vfs.watch=true // GOOD: Use Java toolchains instead of sourceCompatibility java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } // GOOD: Use lazy task registration tasks.register("myTask") { doLast { ... } } // GOOD: Use Provider API for task inputs abstract class MyTask : DefaultTask() { @get:Input abstract val message: Property<String> @TaskAction fun execute() { println(message.get()) } }
Section 2: Plugin/Task Development
This section covers advanced topics for developers building custom Gradle plugins and tasks: proper input/output handling, extensions, lazy configuration with Providers API, and build caching.
Custom Tasks
Lazy vs Eager Task Registration
// BAD: Eager task creation (always executed during configuration) tasks.create("eagerTask") { doLast { println("Task executed") } } // Problem: Task is configured immediately, slowing configuration phase // GOOD: Lazy task registration (configured only when needed) tasks.register("lazyTask") { doLast { println("Task executed") } } // Benefit: Task configured only if needed (e.g., when explicitly run)
Task Actions
// GOOD: Simple task with doLast tasks.register("hello") { doLast { println("Hello from task") } } // GOOD: Multiple actions (executed in order) tasks.register("multiAction") { doFirst { println("First action") } doLast { println("Last action") } } // GOOD: Named action (can be removed later if needed) tasks.register("namedAction") { val myAction = Action<Task> { println("Named action") } doLast(myAction) }
Abstract Task Classes (Recommended for Reusable Tasks)
import org.gradle.api.DefaultTask import org.gradle.api.file.* import org.gradle.api.provider.Property import org.gradle.api.tasks.* // GOOD: Abstract task with typed properties abstract class ProcessFilesTask : DefaultTask() { @get:InputDirectory @get:PathSensitive(PathSensitivity.RELATIVE) abstract val inputDir: DirectoryProperty @get:OutputDirectory abstract val outputDir: DirectoryProperty @get:Input @get:Optional abstract val prefix: Property<String> init { // Set defaults prefix.convention("processed-") } @TaskAction fun process() { val input = inputDir.get().asFile val output = outputDir.get().asFile output.mkdirs() input.listFiles()?.forEach { file -> val processed = output.resolve("${prefix.get()}${file.name}") processed.writeText(file.readText().uppercase()) } println("Processed ${input.listFiles()?.size ?: 0} files") } } // Register task with configuration tasks.register<ProcessFilesTask>("processFiles") { inputDir = layout.projectDirectory.dir("src/data") outputDir = layout.buildDirectory.dir("processed") prefix = "PROCESSED-" }
Input and Output Annotations
Critical for up-to-date checking and caching:
abstract class AdvancedTask : DefaultTask() { // GOOD: Input file @get:InputFile @get:PathSensitive(PathSensitivity.NONE) // Content-only sensitivity abstract val inputFile: RegularFileProperty // GOOD: Input files @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) // Path matters abstract val inputFiles: ConfigurableFileCollection // GOOD: Input directory @get:InputDirectory @get:PathSensitive(PathSensitivity.RELATIVE) abstract val inputDir: DirectoryProperty // GOOD: Input property (string, boolean, etc.) @get:Input abstract val message: Property<String> // GOOD: Optional input @get:Input @get:Optional abstract val optionalFlag: Property<Boolean> // GOOD: Output file @get:OutputFile abstract val outputFile: RegularFileProperty // GOOD: Output directory @get:OutputDirectory abstract val outputDir: DirectoryProperty // GOOD: Internal property (not an input/output) @get:Internal abstract val internalState: Property<String> @TaskAction fun execute() { // Task implementation } }
PathSensitivity options:
- Only file content matters (not path or name)NONE
- File name mattersNAME_ONLY
- Relative path matters (most common)RELATIVE
- Absolute path matters (rare)ABSOLUTE
Task Dependencies
// GOOD: Task depends on another task tasks.register("taskA") { doLast { println("Task A") } } tasks.register("taskB") { dependsOn("taskA") // taskA runs before taskB doLast { println("Task B") } } // GOOD: Multiple dependencies tasks.register("taskC") { dependsOn("taskA", "taskB") doLast { println("Task C") } } // GOOD: Ordering without hard dependency tasks.register("taskD") { mustRunAfter("taskB") // If both run, D runs after B doLast { println("Task D") } } tasks.register("taskE") { shouldRunAfter("taskD") // Ordering hint (not enforced) doLast { println("Task E") } } // GOOD: Finalization tasks.register("taskF") { doLast { println("Task F") } } tasks.register("cleanup") { doLast { println("Cleanup") } } tasks.named("taskF") { finalizedBy("cleanup") // cleanup always runs after taskF }
Working Example: File Processing Task
abstract class TransformMarkdownTask : DefaultTask() { @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) abstract val markdownFiles: ConfigurableFileCollection @get:OutputDirectory abstract val htmlOutputDir: DirectoryProperty @get:Input abstract val title: Property<String> init { title.convention("Documentation") } @TaskAction fun transform() { val outputDir = htmlOutputDir.get().asFile outputDir.mkdirs() markdownFiles.forEach { mdFile -> val htmlFile = outputDir.resolve("${mdFile.nameWithoutExtension}.html") val content = mdFile.readText() htmlFile.writeText(""" <!DOCTYPE html> <html> <head><title>${title.get()}</title></head> <body> <pre>$content</pre> </body> </html> """.trimIndent()) } logger.lifecycle("Transformed ${markdownFiles.files.size} markdown files") } } // Register and configure tasks.register<TransformMarkdownTask>("transformMarkdown") { markdownFiles.from(fileTree("docs") { include("**/*.md") }) htmlOutputDir = layout.buildDirectory.dir("html") title = "My Project Documentation" }
Task Configuration Avoidance
// GOOD: Configure task only when needed tasks.named<JavaCompile>("compileJava") { options.compilerArgs.add("-Xlint:unchecked") } // BAD: Getting task eagerly (forces configuration) // val compileJava = tasks.getByName("compileJava") // Avoid this // GOOD: Lazy task reference val compileJavaTask = tasks.named("compileJava") // GOOD: Configure all tasks of type tasks.withType<Test>().configureEach { useJUnitPlatform() maxParallelForks = Runtime.getRuntime().availableProcessors() }
Extension API
Extensions provide a DSL for configuring plugins. They're essential for creating user-friendly custom plugins.
Simple Extension
// Define extension (in buildSrc or custom plugin) abstract class GreetingExtension { abstract val message: Property<String> abstract val times: Property<Int> init { // Set default values message.convention("Hello") times.convention(1) } } // Register extension in plugin class GreetingPlugin : Plugin<Project> { override fun apply(project: Project) { // Create extension val extension = project.extensions.create("greeting", GreetingExtension::class.java) // Use extension to configure task project.tasks.register("greet") { doLast { repeat(extension.times.get()) { println(extension.message.get()) } } } } } // Usage in build.gradle.kts plugins { id("greeting-plugin") } greeting { message = "Hello, Gradle!" times = 3 }
Extension Anti-Patterns
// BAD: Using plain variables instead of Property<T> abstract class BadExtension { var message: String = "Hello" // Not lazy, not compatible with config cache var times: Int = 1 // Cannot be wired to providers } // Problem: Breaks configuration cache, not lazy, no provider wiring // GOOD: Always use Property<T> abstract class GoodExtension { abstract val message: Property<String> abstract val times: Property<Int> } // BAD: Eager evaluation in extension class BadPlugin : Plugin<Project> { override fun apply(project: Project) { val extension = project.extensions.create("bad", BadExtension::class.java) // Evaluates immediately during configuration! val msg = extension.message.get() // BAD: Too early project.tasks.register("bad") { doLast { println(msg) } // Value captured at configuration time } } } // GOOD: Lazy evaluation with providers class GoodPlugin : Plugin<Project> { override fun apply(project: Project) { val extension = project.extensions.create("good", GoodExtension::class.java) project.tasks.register("good") { doLast { // Evaluated at execution time println(extension.message.get()) } } } } // BAD: No default values abstract class ExtensionWithoutDefaults { abstract val required: Property<String> // User MUST set this or build fails - poor UX } // GOOD: Provide sensible defaults abstract class ExtensionWithDefaults { abstract val optional: Property<String> init { optional.convention("sensible-default") // User can override if needed } }
Extension with Nested Configuration
// Nested extension for database configuration abstract class DatabaseExtension { abstract val host: Property<String> abstract val port: Property<Int> abstract val username: Property<String> abstract val password: Property<String> } // Main extension abstract class AppExtension(objects: ObjectFactory) { // Simple properties abstract val appName: Property<String> abstract val version: Property<String> // Nested object (always created) val database: DatabaseExtension = objects.newInstance(DatabaseExtension::class.java) // Configure nested object with DSL fun database(action: Action<DatabaseExtension>) { action.execute(database) } init { appName.convention("MyApp") version.convention("1.0.0") database.port.convention(5432) } } // Usage in build.gradle.kts app { appName = "CoolApp" version = "2.0.0" database { host = "localhost" port = 5432 username = "admin" password = providers.gradleProperty("db.password").orElse("default") } }
Extension with Named Domain Objects
For collections of similar configurations:
import org.gradle.api.NamedDomainObjectContainer // Define a server configuration abstract class ServerConfig(val name: String) { abstract val host: Property<String> abstract val port: Property<Int> init { port.convention(8080) } } // Extension with container abstract class DeploymentExtension(objects: ObjectFactory) { // Container of servers val servers: NamedDomainObjectContainer<ServerConfig> = objects.domainObjectContainer(ServerConfig::class.java) // DSL method for configuring servers fun servers(action: Action<NamedDomainObjectContainer<ServerConfig>>) { action.execute(servers) } } // Usage in build.gradle.kts deployment { servers { create("production") { host = "prod.example.com" port = 443 } create("staging") { host = "staging.example.com" port = 8080 } } } // Access servers in task tasks.register("deployToProduction") { doLast { val prodServer = extensions.getByType<DeploymentExtension>() .servers.getByName("production") println("Deploying to ${prodServer.host.get()}:${prodServer.port.get()}") } }
Extension Best Practices
abstract class WellDesignedExtension @Inject constructor( private val objects: ObjectFactory, private val providers: ProviderFactory ) { // GOOD: Use Property<T> for mutable configuration abstract val apiKey: Property<String> // GOOD: Provide sensible defaults abstract val timeout: Property<Int> // GOOD: Use Provider for derived values val apiUrl: Provider<String> = apiKey.map { key -> "https://api.example.com?key=$key" } // GOOD: Validate in finalizer (not during configuration) init { timeout.convention(30) // Validation happens when value is accessed apiKey.finalizeValueOnRead() } // GOOD: Provide configuration methods with clear names fun useDefaultCredentials() { apiKey.set(providers.environmentVariable("API_KEY")) } fun useCustomCredentials(key: String) { apiKey.set(key) } } // BAD: Using plain variables (not lazy) // class BadExtension { // var apiKey: String = "" // Not lazy, no defaults, no validation // }
Connecting Extension to Tasks
abstract class PublishExtension { abstract val version: Property<String> abstract val repository: Property<String> init { version.convention("1.0.0") repository.convention("https://repo.example.com") } } class PublishPlugin : Plugin<Project> { override fun apply(project: Project) { val extension = project.extensions.create("publish", PublishExtension::class.java) project.tasks.register<PublishTask>("publish") { // GOOD: Wire extension properties to task properties version.set(extension.version) repository.set(extension.repository) } } } abstract class PublishTask : DefaultTask() { @get:Input abstract val version: Property<String> @get:Input abstract val repository: Property<String> @TaskAction fun publish() { println("Publishing version ${version.get()} to ${repository.get()}") } }
Providers API
The Providers API enables lazy configuration, which is essential for configuration cache and fast builds.
Provider<T> Basics
// GOOD: Provider wraps a value that's computed lazily val messageProvider: Provider<String> = providers.provider { "Message computed at ${System.currentTimeMillis()}" } // Value is only computed when accessed tasks.register("printMessage") { doLast { println(messageProvider.get()) // Computed here } } // GOOD: Provider from environment variable val apiKeyProvider: Provider<String> = providers.environmentVariable("API_KEY") // GOOD: Provider from system property val debugProvider: Provider<String> = providers.systemProperty("debug") // GOOD: Provider from gradle property val versionProvider: Provider<String> = providers.gradleProperty("app.version")
Property<T> for Mutable Values
abstract class ConfigurableTask : DefaultTask() { // GOOD: Property<T> for task inputs (can be set and connected) @get:Input abstract val message: Property<String> @get:Input abstract val count: Property<Int> init { // Set default values message.convention("Default message") count.convention(1) } @TaskAction fun execute() { repeat(count.get()) { println(message.get()) } } } // Configure task tasks.register<ConfigurableTask>("configurable") { message.set("Hello from property") count.set(5) }
Transforming Providers
// GOOD: map - transform provider value val version: Provider<String> = providers.gradleProperty("version") val fullVersion: Provider<String> = version.map { v -> "v$v-${System.currentTimeMillis()}" } // GOOD: flatMap - chain providers val baseUrl: Provider<String> = providers.gradleProperty("baseUrl") val apiUrl: Provider<String> = baseUrl.flatMap { base -> providers.provider { "$base/api/v1" } } // GOOD: orElse - provide fallback val timeout: Provider<Int> = providers.gradleProperty("timeout") .map { it.toInt() } .orElse(30) // GOOD: zip - combine two providers val host: Provider<String> = providers.gradleProperty("host") val port: Provider<Int> = providers.gradleProperty("port").map { it.toInt() } val endpoint: Provider<String> = host.zip(port) { h, p -> "$h:$p" }
Connecting Providers
abstract class SourceTask : DefaultTask() { @get:Input abstract val sourceMessage: Property<String> init { sourceMessage.convention("Source data") } @TaskAction fun execute() { println("Source: ${sourceMessage.get()}") } } abstract class TargetTask : DefaultTask() { @get:Input abstract val targetMessage: Property<String> @TaskAction fun execute() { println("Target: ${targetMessage.get()}") } } // GOOD: Connect provider from one task to another val sourceTask = tasks.register<SourceTask>("source") tasks.register<TargetTask>("target") { // Wire output from source to input of target targetMessage.set(sourceTask.flatMap { it.sourceMessage }) }
File and Directory Providers
abstract class FileTask : DefaultTask() { // GOOD: Use RegularFileProperty for files @get:OutputFile abstract val outputFile: RegularFileProperty // GOOD: Use DirectoryProperty for directories @get:OutputDirectory abstract val outputDir: DirectoryProperty @TaskAction fun execute() { // Get file and directory val file = outputFile.get().asFile val dir = outputDir.get().asFile file.writeText("Output content") println("Wrote to ${file.absolutePath}") } } tasks.register<FileTask>("fileTask") { // GOOD: Use layout.buildDirectory for build outputs outputFile.set(layout.buildDirectory.file("output.txt")) outputDir.set(layout.buildDirectory.dir("outputs")) } // GOOD: Map file providers tasks.register("processFile") { val inputProvider: Provider<RegularFile> = layout.buildDirectory.file("input.txt") val outputProvider: Provider<RegularFile> = inputProvider.map { input -> layout.buildDirectory.file("processed-${input.asFile.name}").get() } }
Collection Providers
abstract class CollectionTask : DefaultTask() { // GOOD: ListProperty for list of values @get:Input abstract val items: ListProperty<String> // GOOD: SetProperty for unique values @get:Input abstract val tags: SetProperty<String> // GOOD: MapProperty for key-value pairs @get:Input abstract val config: MapProperty<String, String> @TaskAction fun execute() { println("Items: ${items.get()}") println("Tags: ${tags.get()}") println("Config: ${config.get()}") } } tasks.register<CollectionTask>("collections") { // Set collections items.set(listOf("a", "b", "c")) items.add("d") // Add single item tags.set(setOf("gradle", "kotlin")) tags.add("build") config.set(mapOf("env" to "prod", "region" to "us")) config.put("version", "1.0") }
Common Anti-Patterns to Avoid
// BAD: Eager evaluation during configuration // val version = project.findProperty("version") as String // Evaluated immediately // GOOD: Lazy evaluation with provider val version: Provider<String> = providers.gradleProperty("version") // BAD: Calling .get() during configuration phase // tasks.register("bad") { // val msg = messageProvider.get() // Forces evaluation too early // doLast { println(msg) } // } // GOOD: Call .get() only in task action tasks.register("good") { doLast { println(messageProvider.get()) // Evaluated at execution time } } // BAD: Using plain variables in task configuration // var myVar = "value" // tasks.register("bad") { // doLast { println(myVar) } // Captures current value, not lazy // } // GOOD: Using properties abstract class GoodTask : DefaultTask() { @get:Input abstract val myProperty: Property<String> @TaskAction fun execute() { println(myProperty.get()) // Lazy, cached, compatible with config cache } }
Provider Best Practices
// GOOD: Use providers for external inputs val externalConfig: Provider<String> = providers.fileContents( layout.projectDirectory.file("config.txt") ).asText // GOOD: Finalize values to catch configuration errors early val criticalValue: Property<String> = objects.property(String::class.java) criticalValue.finalizeValueOnRead() // Value can't change after first read // GOOD: Use conventions for defaults val timeout: Property<Int> = objects.property(Int::class.java) timeout.convention(30) // Default value if not set // GOOD: Validate provider values val port: Provider<Int> = providers.gradleProperty("port") .map { it.toInt() } .map { p -> require(p in 1..65535) { "Port must be between 1 and 65535" } p } // GOOD: Use providers.provider for expensive computations val expensiveValue: Provider<String> = providers.provider { // This expensive computation only runs when needed Thread.sleep(100) "Computed value" }
Gradle Caching
Caching is essential for fast Gradle builds. There are two types of caching: build cache (task outputs) and configuration cache (build configuration).
Build Cache Basics
The build cache stores task outputs and reuses them when inputs haven't changed.
Enable build cache (gradle.properties):
org.gradle.caching=true
Or via command line:
./gradlew build --build-cache
How it works:
- Gradle calculates cache key from task inputs
- If cache hit: Reuses outputs, task shows "FROM-CACHE"
- If cache miss: Executes task, stores outputs
Writing Cache-Compatible Tasks
import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.* // GOOD: Cache-compatible task with proper annotations @CacheableTask // Mark class as cacheable (must import org.gradle.api.tasks.CacheableTask) abstract class CacheableProcessTask : DefaultTask() { @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) abstract val inputFiles: ConfigurableFileCollection @get:Input abstract val processMode: Property<String> @get:OutputDirectory abstract val outputDir: DirectoryProperty @TaskAction fun process() { val output = outputDir.get().asFile output.mkdirs() inputFiles.forEach { file -> val processed = output.resolve(file.name) when (processMode.get()) { "uppercase" -> processed.writeText(file.readText().uppercase()) "lowercase" -> processed.writeText(file.readText().lowercase()) } } } } // BAD: Task that's not cacheable (no @CacheableTask, uses external state) abstract class BadTask : DefaultTask() { @TaskAction fun execute() { val timestamp = System.currentTimeMillis() // Non-deterministic! File("output.txt").writeText("Built at $timestamp") } }
What Makes Tasks Cacheable
✅ GOOD - Cacheable:
- Deterministic outputs (same inputs → same outputs)
- All inputs properly annotated (@InputFiles, @Input, etc.)
- No external state (environment, timestamps, random)
- Uses Provider API for configuration
- Marked with @CacheableTask at class level
- Uses @PathSensitive for file inputs
❌ BAD - Not Cacheable:
- Non-deterministic (timestamps, random values, System.currentTimeMillis())
- Missing input/output annotations
- Depends on external state not declared as inputs
- Modifies state outside task outputs
- No @CacheableTask annotation at class level
Making Built-in Tasks Cacheable
// GOOD: Configure tasks to be cacheable tasks.withType<Test>().configureEach { outputs.cacheIf { true } // Enable caching for tests } // GOOD: Normalize file paths for cache portability normalization { runtimeClasspath { ignore("META-INF/MANIFEST.MF") // Ignore non-functional differences } }
Configuration Cache
Configuration cache stores the configured task graph, eliminating configuration phase on subsequent builds.
Enable configuration cache (gradle.properties):
org.gradle.configuration-cache=true org.gradle.configuration-cache.problems=warn # Or 'fail'
Or via command line:
./gradlew build --configuration-cache
Benefits:
- Up to 90% faster builds (no configuration phase)
- Second build reuses cached configuration
- Encourages better build practices
Configuration Cache Compatibility
// GOOD: Configuration cache compatible tasks.register("compatible") { val message: Provider<String> = providers.gradleProperty("message") doLast { println(message.get()) // Lazy evaluation } } // BAD: Not configuration cache compatible // tasks.register("incompatible") { // val message = project.findProperty("message") // Eager evaluation // doLast { // println(message) // Captures project state at configuration time // } // } // GOOD: Use build services for shared state interface MyBuildService : BuildService<BuildServiceParameters.None> { fun performWork() { println("Build service performing work") } } abstract class SharedStateTask : DefaultTask() { @get:Internal // Build services are not inputs abstract val myService: Property<MyBuildService> @TaskAction fun execute() { myService.get().performWork() } } // Register the build service val myServiceProvider = gradle.sharedServices.registerIfAbsent("myService", MyBuildService::class) { // Configure service parameters here if needed } // Wire service to task tasks.register<SharedStateTask>("taskWithService") { myService.set(myServiceProvider) } // BAD: Using static/global state instead of build services object BadSharedState { var counter = 0 // Mutable global state - not serializable! } // Problem: Breaks configuration cache, not thread-safe, not isolated // BAD: Trying to share data via files without proper task dependencies tasks.register("badProducer") { doLast { File("shared.txt").writeText("data") // No output annotation! } } tasks.register("badConsumer") { doLast { val data = File("shared.txt").readText() // No input annotation! println(data) } } // Problem: No dependency declared, may run in wrong order or break caching // GOOD: Use task outputs/inputs or build services for shared state
Common Configuration Cache Issues
// PROBLEM: Accessing project at execution time // tasks.register("bad") { // doLast { // println(project.name) // Configuration cache error! // } // } // SOLUTION: Capture value during configuration tasks.register("good") { val projectName = project.name // Captured during configuration doLast { println(projectName) // OK - uses captured value } } // PROBLEM: Using mutable shared state // val sharedList = mutableListOf<String>() // tasks.register("bad") { // doLast { // sharedList.add("item") // Not serializable! // } // } // SOLUTION: Use build services or task outputs abstract class GoodTask : DefaultTask() { @get:OutputFile abstract val outputFile: RegularFileProperty @TaskAction fun execute() { outputFile.get().asFile.appendText("item\n") } }
Cache Debugging
# Check what's not cacheable ./gradlew build --build-cache --info | grep "Caching disabled" # Explain why task wasn't cached ./gradlew help --task processFiles # Clear build cache rm -rf ~/.gradle/caches/build-cache-* rm -rf .gradle/build-cache # Check configuration cache problems ./gradlew build --configuration-cache --configuration-cache-problems=warn # Rerun without cache to compare ./gradlew clean build --no-build-cache --no-configuration-cache ./gradlew clean build --build-cache --configuration-cache
Remote Build Cache
// settings.gradle.kts buildCache { local { isEnabled = true } remote<HttpBuildCache> { url = uri("https://cache.example.com/") isPush = providers.environmentVariable("CI") .map { it == "true" } .getOrElse(false) credentials { username = providers.environmentVariable("CACHE_USER").orNull password = providers.environmentVariable("CACHE_PASSWORD").orNull } } } // Benefits: // - Share cache across CI and developers // - Dramatically faster CI builds // - Consistent build performance
Cache Performance Tips
// GOOD: Use relative path sensitivity when possible abstract class OptimizedTask : DefaultTask() { @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) // Better cache hits abstract val sources: ConfigurableFileCollection } // GOOD: Exclude non-functional files normalization { runtimeClasspath { ignore("**/*.txt") // If .txt files don't affect behavior ignore("META-INF/MANIFEST.MF") } } // GOOD: Use file collections instead of file trees for better caching val sources: ConfigurableFileCollection = objects.fileCollection() sources.from(fileTree("src") { include("**/*.java") }) // GOOD: Split large tasks into smaller cacheable units tasks.register("compileAll") { dependsOn("compileModule1", "compileModule2", "compileModule3") } // Each module compiled separately = better cache granularity
Measuring Cache Effectiveness
# Build scan (best way to analyze caching) ./gradlew build --scan # The build scan shows: # - Cache hit rate # - Which tasks were cached # - Why tasks were not cached # - Performance timeline # Enable with: # plugins { # id("com.gradle.develocity") version "3.16" # } # # develocity { # buildScan { # publishing.onlyIf { true } # } # }
Custom Plugins
Custom plugins encapsulate build logic for reuse across projects or modules.
Binary Plugin (Plugin<Project>)
// buildSrc/src/main/kotlin/GreetingPlugin.kt import org.gradle.api.Plugin import org.gradle.api.Project class GreetingPlugin : Plugin<Project> { override fun apply(project: Project) { // Create extension for configuration val extension = project.extensions.create( "greeting", GreetingExtension::class.java ) // Register task that uses extension project.tasks.register("greet") { group = "custom" description = "Prints a greeting message" doLast { repeat(extension.times.get()) { println(extension.message.get()) } } } } } // Extension for configuration abstract class GreetingExtension { abstract val message: Property<String> abstract val times: Property<Int> init { message.convention("Hello from plugin") times.convention(1) } } // buildSrc/src/main/resources/META-INF/gradle-plugins/greeting.properties implementation-class=GreetingPlugin // Usage in build.gradle.kts: // plugins { // id("greeting") // } // // greeting { // message = "Hello, World!" // times = 3 // }
Precompiled Script Plugin (Recommended for Simple Plugins)
Easier approach using Kotlin DSL directly:
// buildSrc/src/main/kotlin/java-library-conventions.gradle.kts plugins { `java-library` `maven-publish` } java { toolchain { languageVersion = JavaLanguageVersion.of(21) } withSourcesJar() withJavadocJar() } repositories { mavenCentral() } dependencies { testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } tasks.test { useJUnitPlatform() } publishing { publications { create<MavenPublication>("maven") { from(components["java"]) } } } // Usage in build.gradle.kts: // plugins { // id("java-library-conventions") // }
Complete Plugin Example
// buildSrc/src/main/kotlin/DocumentationPlugin.kt import org.gradle.api.* import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.* class DocumentationPlugin : Plugin<Project> { override fun apply(project: Project) { // Register extension val extension = project.extensions.create( "documentation", DocumentationExtension::class.java ) // Configure defaults from extension extension.sourceDir.convention( project.layout.projectDirectory.dir("docs") ) extension.outputDir.convention( project.layout.buildDirectory.dir("docs") ) // Register task project.tasks.register<GenerateDocsTask>("generateDocs") { sourceDir.set(extension.sourceDir) outputDir.set(extension.outputDir) format.set(extension.format) group = "documentation" description = "Generates documentation" } // Hook into build lifecycle project.tasks.named("build") { dependsOn("generateDocs") } } } abstract class DocumentationExtension { abstract val sourceDir: DirectoryProperty abstract val outputDir: DirectoryProperty abstract val format: Property<String> init { format.convention("html") } } abstract class GenerateDocsTask : DefaultTask() { @get:InputDirectory @get:PathSensitive(PathSensitivity.RELATIVE) abstract val sourceDir: DirectoryProperty @get:OutputDirectory abstract val outputDir: DirectoryProperty @get:Input abstract val format: Property<String> @TaskAction fun generate() { val output = outputDir.get().asFile output.mkdirs() sourceDir.get().asFileTree.forEach { file -> val outputFile = output.resolve("${file.nameWithoutExtension}.${format.get()}") outputFile.writeText("Generated from ${file.name}") } logger.lifecycle("Generated documentation in ${output.absolutePath}") } }
Plugin with Build Service
For shared state across tasks:
import org.gradle.api.services.BuildService import org.gradle.api.services.BuildServiceParameters abstract class MetricsService : BuildService<BuildServiceParameters.None> { private val metrics = mutableMapOf<String, Long>() fun record(metric: String, value: Long) { metrics[metric] = value } fun report() { println("Build Metrics:") metrics.forEach { (key, value) -> println(" $key: $value") } } } class MetricsPlugin : Plugin<Project> { override fun apply(project: Project) { // Register build service val metricsService = project.gradle.sharedServices.registerIfAbsent( "metrics", MetricsService::class.java ) {} // Use service in tasks project.tasks.register<MetricsTask>("recordMetrics") { this.metricsService.set(metricsService) } // Report at end of build project.gradle.buildFinished { metricsService.get().report() } } } abstract class MetricsTask : DefaultTask() { @get:ServiceReference("metrics") abstract val metricsService: Property<MetricsService> @TaskAction fun record() { metricsService.get().record("task_count", 42) } }
Testing Custom Plugins
// buildSrc/src/test/kotlin/GreetingPluginTest.kt import org.gradle.testfixtures.ProjectBuilder import org.junit.jupiter.api.Test import org.junit.jupiter.api.Assertions.* class GreetingPluginTest { @Test fun `plugin registers greet task`() { val project = ProjectBuilder.builder().build() project.pluginManager.apply("greeting") val task = project.tasks.findByName("greet") assertNotNull(task) } @Test fun `extension has default values`() { val project = ProjectBuilder.builder().build() project.pluginManager.apply("greeting") val extension = project.extensions.getByType(GreetingExtension::class.java) assertEquals("Hello from plugin", extension.message.get()) assertEquals(1, extension.times.get()) } @Test fun `can configure extension`() { val project = ProjectBuilder.builder().build() project.pluginManager.apply("greeting") val extension = project.extensions.getByType(GreetingExtension::class.java) extension.message.set("Custom message") extension.times.set(5) assertEquals("Custom message", extension.message.get()) assertEquals(5, extension.times.get()) } }
Publishing Plugins
// buildSrc/build.gradle.kts (for publishing to plugin portal) plugins { `kotlin-dsl` `maven-publish` id("com.gradle.plugin-publish") version "1.2.1" } group = "com.example" version = "1.0.0" gradlePlugin { website = "https://github.com/example/plugin" vcsUrl = "https://github.com/example/plugin" plugins { create("greetingPlugin") { id = "com.example.greeting" displayName = "Greeting Plugin" description = "A plugin that greets users" tags = listOf("greeting", "example") implementationClass = "com.example.GreetingPlugin" } } } publishing { repositories { maven { name = "Internal" url = uri("https://repo.company.com/maven") } } } // Publish with: ./gradlew publishPlugins
Plugin Best Practices
class WellDesignedPlugin : Plugin<Project> { override fun apply(project: Project) { // GOOD: Validate environment require(project.hasProperty("requiredProp")) { "Plugin requires 'requiredProp' property" } // GOOD: Use lazy registration val extension = project.extensions.create("wellDesigned", Extension::class.java) // GOOD: Register tasks lazily project.tasks.register("myTask") { // Configure with extension } // GOOD: Apply other plugins if needed project.pluginManager.apply("java") // GOOD: Configure other plugins project.plugins.withType<JavaPlugin> { project.java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } } } // GOOD: Hook into lifecycle cleanly project.afterEvaluate { // Configuration that needs evaluated project } } } // BAD: Applying plugins eagerly // project.apply(plugin = "java") // Use pluginManager.apply() // BAD: Configuring in constructor // class BadPlugin : Plugin<Project> { // init { // // Plugin not yet applied! // } // }
Build Logic Reuse
There are multiple strategies for sharing build logic across projects and modules.
Strategy 1: buildSrc (Simplest)
Best for: Single repository, convention plugins, shared code within one project.
project/ ├── buildSrc/ │ ├── build.gradle.kts │ ├── settings.gradle.kts │ └── src/ │ └── main/kotlin/ │ ├── java-conventions.gradle.kts │ ├── kotlin-conventions.gradle.kts │ └── MyCustomPlugin.kt ├── app/ │ └── build.gradle.kts └── lib/ └── build.gradle.kts
buildSrc/build.gradle.kts:
plugins { `kotlin-dsl` } repositories { mavenCentral() gradlePluginPortal() } dependencies { // Add dependencies needed by your plugins implementation("com.github.johnrengelman:shadow:8.1.1") }
buildSrc/settings.gradle.kts:
rootProject.name = "buildSrc" dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) repositories { mavenCentral() gradlePluginPortal() } }
buildSrc/src/main/kotlin/java-conventions.gradle.kts:
plugins { java } java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } repositories { mavenCentral() } dependencies { testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") } tasks.test { useJUnitPlatform() }
Usage in app/build.gradle.kts:
plugins { id("java-conventions") // Automatically available application }
Pros:
- Simple setup
- Automatically available to all modules
- Fast incremental builds
Cons:
- Tied to single project
- Changes require Gradle daemon restart
- Can't be versioned separately
Strategy 2: Included Builds (Flexible)
Best for: Multi-repo setups, versioned build logic, independent releases.
company-builds/ ├── my-project/ │ ├── settings.gradle.kts (includes build-logic) │ ├── app/ │ └── lib/ └── build-logic/ ├── settings.gradle.kts ├── build.gradle.kts └── src/ └── main/kotlin/ └── conventions/ ├── java-conventions.gradle.kts └── kotlin-conventions.gradle.kts
my-project/settings.gradle.kts:
rootProject.name = "my-project" // Include build-logic includeBuild("../build-logic") include("app") include("lib")
build-logic/settings.gradle.kts:
rootProject.name = "build-logic" dependencyResolutionManagement { repositories { mavenCentral() gradlePluginPortal() } }
build-logic/build.gradle.kts:
plugins { `kotlin-dsl` } group = "com.example.build" version = "1.0.0" dependencies { implementation("com.github.johnrengelman:shadow:8.1.1") } gradlePlugin { plugins { register("javaConventions") { id = "com.example.java-conventions" implementationClass = "conventions.JavaConventionsPlugin" } } }
Usage in my-project/app/build.gradle.kts:
plugins { id("com.example.java-conventions") }
Pros:
- Independent versioning
- No daemon restart needed
- Shareable across projects
- Can be published
Cons:
- More complex setup
- Need to manage versions
Strategy 3: Published Plugins (Enterprise)
Best for: Many projects, organization-wide standards, versioned releases.
Plugin Project Structure:
gradle-plugins/ ├── settings.gradle.kts ├── build.gradle.kts └── src/ └── main/ ├── kotlin/ │ └── com/example/plugins/ │ └── JavaConventionsPlugin.kt └── resources/ └── META-INF/gradle-plugins/ └── com.example.java-conventions.properties
build.gradle.kts:
plugins { `kotlin-dsl` `maven-publish` } group = "com.example.gradle" version = "1.0.0" java { toolchain { languageVersion = JavaLanguageVersion.of(11) // Wide compatibility } } publishing { repositories { maven { name = "Company" url = uri("https://repo.company.com/maven") credentials { username = System.getenv("REPO_USER") password = System.getenv("REPO_PASSWORD") } } } publications { create<MavenPublication>("plugin") { from(components["java"]) } } }
Consumer settings.gradle.kts:
pluginManagement { repositories { maven { url = uri("https://repo.company.com/maven") } gradlePluginPortal() } }
Consumer build.gradle.kts:
plugins { id("com.example.java-conventions") version "1.0.0" }
Pros:
- Enterprise-grade
- Versioned releases
- Change management
- Works across all projects
Cons:
- Most complex
- Release overhead
- Version management needed
Strategy 4: Composite Builds (Advanced)
Best for: Multiple independent projects that need to work together.
workspace/ ├── project-a/ │ ├── settings.gradle.kts │ └── build.gradle.kts ├── project-b/ │ ├── settings.gradle.kts │ └── build.gradle.kts └── shared-library/ ├── settings.gradle.kts └── build.gradle.kts
project-a/settings.gradle.kts:
rootProject.name = "project-a" // Include other project as composite build includeBuild("../shared-library")
project-a/build.gradle.kts:
dependencies { // Depend on shared library implementation("com.example:shared-library:1.0.0") // Gradle substitutes with composite build automatically }
Pros:
- Independent projects
- Source dependencies
- IDE integration
- Parallel development
Cons:
- Complex setup
- Dependency substitution rules needed
Choosing the Right Strategy
| Scenario | Recommended Strategy |
|---|---|
| Single repo, simple conventions | buildSrc |
| Multi-repo, same org | Included builds |
| Organization-wide standards | Published plugins |
| Multiple independent projects | Composite builds |
| Experimenting with new patterns | buildSrc → Included builds |
Convention Plugin Patterns
Important: Precompiled script plugins in buildSrc automatically get plugin IDs based on their file path. A file at
buildSrc/src/main/kotlin/conventions/java-base.gradle.kts becomes plugin id("conventions.java-base").
// Pattern 1: Pure configuration plugin // Location: buildSrc/src/main/kotlin/conventions/java-base.gradle.kts // Plugin ID: conventions.java-base (auto-generated from file path) plugins { java } java { toolchain.languageVersion = JavaLanguageVersion.of(21) } // Pattern 2: Conditional configuration // Location: buildSrc/src/main/kotlin/conventions/java-app.gradle.kts // Plugin ID: conventions.java-app plugins { id("conventions.java-base") // References Pattern 1 by its auto-generated ID application } if (project.hasProperty("enableJacoco")) { apply(plugin = "jacoco") } // Pattern 3: Composed plugins // conventions/java-library.gradle.kts plugins { id("conventions.java-base") `java-library` `maven-publish` } publishing { publications { create<MavenPublication>("maven") { from(components["java"]) } } }
Sharing Configuration Files
// buildSrc/src/main/resources/checkstyle.xml // buildSrc/src/main/kotlin/conventions.gradle.kts plugins { checkstyle } checkstyle { configFile = file("${project.rootDir}/buildSrc/src/main/resources/checkstyle.xml") toolVersion = "10.12.5" } // All modules get consistent checkstyle configuration
Version Management for Shared Logic
// build-logic/build.gradle.kts version = "1.2.0" // Increment when making changes // Consumers can pin versions // settings.gradle.kts pluginManagement { resolutionStrategy { eachPlugin { if (requested.id.id == "com.example.conventions") { useVersion("1.2.0") } } } }
Groovy → Kotlin DSL Migration Guide
This section helps developers migrate existing Groovy DSL build scripts to Kotlin DSL.
Syntax Differences
This section provides side-by-side comparisons of common syntax patterns.
Basic Syntax Table
| Feature | Groovy DSL | Kotlin DSL |
|---|---|---|
| File name | | |
| Settings file | | |
| Assignment | | |
| String literals | or | only |
| String interpolation | | |
| Method calls | | |
| Configuration blocks | | |
| Task configuration | | |
Assignment and Properties
Groovy:
version = '1.0.0' group = 'com.example' ext.customProp = 'value' ext { anotherProp = 'value' }
Kotlin:
version = "1.0.0" group = "com.example" extra["customProp"] = "value" // Or with type-safe accessor val customProp by extra("value")
String Literals
Groovy:
// Both work in Groovy implementation 'com.google.guava:guava:33.0.0-jre' implementation "com.google.guava:guava:33.0.0-jre" // String interpolation def myVersion = '1.0' println "Version: $myVersion"
Kotlin:
// Only double quotes work in Kotlin implementation("com.google.guava:guava:33.0.0-jre") // String interpolation (same as Groovy) val myVersion = "1.0" println("Version: $myVersion")
Method Call Syntax
Groovy (implicit parentheses):
// Groovy allows omitting parentheses implementation 'com.google.guava:guava:33.0.0-jre' testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' // Configuration blocks repositories { mavenCentral() }
Kotlin (explicit parentheses):
// Kotlin requires parentheses for method calls implementation("com.google.guava:guava:33.0.0-jre") testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") // Configuration blocks (same) repositories { mavenCentral() }
Property Access vs Method Calls
Groovy:
// Groovy uses property syntax for getters/setters java { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } tasks.test { maxParallelForks = 4 }
Kotlin:
// Kotlin also uses property syntax java { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } tasks.named<Test>("test") { maxParallelForks = 4 }
Collection Literals
Groovy:
// List def myList = ['item1', 'item2', 'item3'] // Map def myMap = [key1: 'value1', key2: 'value2']
Kotlin:
// List val myList = listOf("item1", "item2", "item3") // Map val myMap = mapOf("key1" to "value1", "key2" to "value2")
Configuration Delegation
Groovy (implicit delegate):
tasks.create('myTask') { // 'doLast' resolves through task delegate doLast { println 'Task executed' } }
Kotlin (explicit receiver):
tasks.register("myTask") { // 'this' refers to the task doLast { println("Task executed") } }
Plugin Application Conversion
Old apply Syntax to plugins Block
Groovy (old style):
apply plugin: 'java' apply plugin: 'application' apply plugin: 'com.github.johnrengelman.shadow' buildscript { repositories { gradlePluginPortal() } dependencies { classpath 'com.github.johnrengelman:shadow:8.1.1' } }
Kotlin (modern style):
plugins { java application id("com.github.johnrengelman.shadow") version "8.1.1" } // No buildscript block needed for plugins from Gradle Plugin Portal
Core Plugins
Groovy:
apply plugin: 'java' apply plugin: 'java-library' apply plugin: 'application' apply plugin: 'groovy'
Kotlin:
plugins { java `java-library` // Note backticks for kebab-case application groovy }
Kotlin Plugins
Groovy:
apply plugin: 'org.jetbrains.kotlin.jvm' buildscript { dependencies { classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.0' } }
Kotlin:
plugins { kotlin("jvm") version "2.0.0" // Short form for Kotlin plugins // Alternative: id("org.jetbrains.kotlin.jvm") version "2.0.0" }
Applying Plugins Conditionally
Groovy:
if (project.hasProperty('enableKotlin')) { apply plugin: 'org.jetbrains.kotlin.jvm' }
Kotlin:
plugins { java if (project.hasProperty("enableKotlin")) { kotlin("jvm") version "2.0.0" } } // Alternative: Apply outside plugins block if (project.findProperty("enableKotlin") == "true") { apply(plugin = "org.jetbrains.kotlin.jvm") }
apply false for Root Projects
Groovy:
plugins { id 'org.springframework.boot' version '3.2.0' apply false }
Kotlin:
plugins { id("org.springframework.boot") version "3.2.0" apply false }
Dependency Declaration Differences
Basic Dependency Notation
Groovy:
dependencies { implementation 'com.google.guava:guava:33.0.0-jre' testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' compileOnly 'org.projectlombok:lombok:1.18.30' runtimeOnly 'com.h2database:h2:2.2.224' }
Kotlin:
dependencies { implementation("com.google.guava:guava:33.0.0-jre") testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") compileOnly("org.projectlombok:lombok:1.18.30") runtimeOnly("com.h2database:h2:2.2.224") }
Map Notation
Groovy:
dependencies { implementation group: 'com.google.guava', name: 'guava', version: '33.0.0-jre' implementation([group: 'com.google.guava', name: 'guava', version: '33.0.0-jre']) }
Kotlin:
dependencies { implementation(group = "com.google.guava", name = "guava", version = "33.0.0-jre") // Or stick with string notation (more common) implementation("com.google.guava:guava:33.0.0-jre") }
Excluding Dependencies
Groovy:
dependencies { implementation('com.example:library:1.0') { exclude group: 'commons-logging', module: 'commons-logging' } }
Kotlin:
dependencies { implementation("com.example:library:1.0") { exclude(group = "commons-logging", module = "commons-logging") } }
Platform/BOM Dependencies
Groovy:
dependencies { implementation platform('org.springframework.boot:spring-boot-dependencies:3.2.0') implementation 'org.springframework.boot:spring-boot-starter-web' }
Kotlin:
dependencies { implementation(platform("org.springframework.boot:spring-boot-dependencies:3.2.0")) implementation("org.springframework.boot:spring-boot-starter-web") }
Project Dependencies
Groovy:
dependencies { implementation project(':common') implementation project(path: ':lib', configuration: 'testFixtures') }
Kotlin:
dependencies { implementation(project(":common")) implementation(project(path = ":lib", configuration = "testFixtures")) // With type-safe accessors (requires TYPESAFE_PROJECT_ACCESSORS) implementation(projects.common) }
File Dependencies
Groovy:
dependencies { implementation files('libs/custom.jar') implementation fileTree(dir: 'libs', include: '*.jar') }
Kotlin:
dependencies { implementation(files("libs/custom.jar")) implementation(fileTree("libs") { include("*.jar") }) }
Configuration-Specific Dependencies
Groovy:
configurations { integrationTestImplementation.extendsFrom testImplementation integrationTestRuntimeOnly.extendsFrom testRuntimeOnly } dependencies { integrationTestImplementation 'org.testcontainers:junit-jupiter:1.19.3' }
Kotlin:
val integrationTestImplementation by configurations.creating { extendsFrom(configurations.testImplementation.get()) } val integrationTestRuntimeOnly by configurations.creating { extendsFrom(configurations.testRuntimeOnly.get()) } dependencies { integrationTestImplementation("org.testcontainers:junit-jupiter:1.19.3") }
Configuration Block Conversions
allprojects and subprojects
Groovy:
allprojects { group = 'com.example' version = '1.0.0' repositories { mavenCentral() } } subprojects { apply plugin: 'java' dependencies { testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' } }
Kotlin:
allprojects { group = "com.example" version = "1.0.0" repositories { mavenCentral() } } subprojects { apply(plugin = "java") dependencies { testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") } }
buildscript Block (Avoid in Modern Gradle)
Groovy:
buildscript { repositories { gradlePluginPortal() mavenCentral() } dependencies { classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.0' } }
Kotlin (old way):
buildscript { repositories { gradlePluginPortal() mavenCentral() } dependencies { classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.0") } }
Kotlin (modern way - use plugins block instead):
plugins { kotlin("jvm") version "2.0.0" } // No buildscript needed!
Task Configuration
Groovy:
task myTask { doLast { println 'Task executed' } } tasks.withType(Test) { useJUnitPlatform() } tasks.named('build') { dependsOn 'myTask' }
Kotlin:
tasks.register("myTask") { doLast { println("Task executed") } } tasks.withType<Test> { useJUnitPlatform() } tasks.named("build") { dependsOn("myTask") }
Java Configuration
Groovy:
java { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 withSourcesJar() withJavadocJar() } compileJava { options.encoding = 'UTF-8' }
Kotlin:
java { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 withSourcesJar() withJavadocJar() } tasks.named<JavaCompile>("compileJava") { options.encoding = "UTF-8" }
Publishing Configuration
Groovy:
publishing { publications { maven(MavenPublication) { from components.java groupId = 'com.example' artifactId = 'my-library' version = '1.0.0' } } repositories { maven { url = 'https://repo.example.com/maven' credentials { username = project.findProperty('repoUser') password = project.findProperty('repoPassword') } } } }
Kotlin:
publishing { publications { create<MavenPublication>("maven") { from(components["java"]) groupId = "com.example" artifactId = "my-library" version = "1.0.0" } } repositories { maven { url = uri("https://repo.example.com/maven") credentials { username = providers.gradleProperty("repoUser").orNull password = providers.gradleProperty("repoPassword").orNull } } } }
Testing Configuration
Groovy:
test { useJUnitPlatform() testLogging { events 'passed', 'skipped', 'failed' exceptionFormat 'full' } maxParallelForks = Runtime.runtime.availableProcessors() }
Kotlin:
tasks.named<Test>("test") { useJUnitPlatform() testLogging { events("passed", "skipped", "failed") exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL } maxParallelForks = Runtime.getRuntime().availableProcessors() }
Extra Properties
Groovy:
ext { springBootVersion = '3.2.0' junitVersion = '5.10.2' } ext.kotlinVersion = '2.0.0' dependencies { implementation "org.springframework.boot:spring-boot-starter-web:$springBootVersion" testImplementation "org.junit.jupiter:junit-jupiter:$junitVersion" }
Kotlin:
// Option 1: Using extra properties extra["springBootVersion"] = "3.2.0" extra["junitVersion"] = "5.10.2" dependencies { implementation("org.springframework.boot:spring-boot-starter-web:${extra["springBootVersion"]}") testImplementation("org.junit.jupiter:junit-jupiter:${extra["junitVersion"]}") } // Option 2: Type-safe delegates (recommended) val springBootVersion by extra("3.2.0") val junitVersion by extra("5.10.2") dependencies { implementation("org.springframework.boot:spring-boot-starter-web:$springBootVersion") testImplementation("org.junit.jupiter:junit-jupiter:$junitVersion") } // Option 3: Regular Kotlin variables (best for build script only) val springBootVersion = "3.2.0" val junitVersion = "5.10.2" dependencies { implementation("org.springframework.boot:spring-boot-starter-web:$springBootVersion") testImplementation("org.junit.jupiter:junit-jupiter:$junitVersion") }
Common Migration Gotchas
1. String Quotes - Single vs Double
Problem:
// ERROR: Single quotes don't work in Kotlin implementation('com.google.guava:guava:33.0.0-jre') // Compilation error!
Solution:
// Use double quotes in Kotlin implementation("com.google.guava:guava:33.0.0-jre") // Correct
Why: Kotlin only supports double quotes for strings. Single quotes are for
Char type.
2. Method Call Parentheses
Problem:
// ERROR: Missing parentheses implementation "com.google.guava:guava:33.0.0-jre" // Compilation error!
Solution:
// Always use parentheses for method calls implementation("com.google.guava:guava:33.0.0-jre") // Correct
Why: Kotlin requires explicit parentheses for method calls (no implicit syntax like Groovy).
3. Plugin ID Strings - kotlin vs "kotlin"
Problem:
plugins { // ERROR: This doesn't work in Kotlin DSL kotlin("jvm") version 2.0.0 // Compilation error - version is not a string! }
Solution:
plugins { // Correct: Version must be a string literal kotlin("jvm") version "2.0.0" // Correct // Alternative explicit form id("org.jetbrains.kotlin.jvm") version "2.0.0" // Also correct }
Why: The
version infix function expects a string parameter, not an expression.
4. Accessing Task by Name - Type Safety
Problem:
// Groovy way (not type-safe) tasks.getByName("test") { useJUnitPlatform() // No type information! }
Solution:
// Kotlin way (type-safe) tasks.named<Test>("test") { useJUnitPlatform() // Type-safe! 'this' is Test } // Or using getByName with cast tasks.getByName<Test>("test") { useJUnitPlatform() }
Why: Kotlin DSL encourages type-safe accessors for better IDE support and compile-time checks.
5. Configuration Names with Hyphens
Problem:
plugins { // ERROR: Hyphens in plugin names need backticks java-library // Compilation error! }
Solution:
plugins { // Use backticks for kebab-case names `java-library` // Correct }
Why: Hyphens aren't valid in Kotlin identifiers, so backticks escape them.
6. Assignment vs Method Calls in Configuration
Problem:
// Confusing when to use = vs method call task { description = "My task" // Property assignment group("custom") // Method call? }
Solution:
tasks.register("myTask") { description = "My task" // Property assignment (setter) group = "custom" // Also property assignment! // Both work, but property syntax is more common in Kotlin doLast { println("Task executed") } }
Why: Kotlin has property syntax for getters/setters. Use
= for properties, () for methods.
7. Extra Properties Access
Problem:
// Groovy style (not type-safe) ext.myVersion = "1.0" println(ext.myVersion) // Error in Kotlin!
Solution:
// Option 1: Map-style access extra["myVersion"] = "1.0" println(extra["myVersion"]) // Option 2: Type-safe delegate (recommended) val myVersion by extra("1.0") println(myVersion) // Option 3: Regular Kotlin variable (simplest) val myVersion = "1.0" println(myVersion)
Why: Kotlin DSL uses
extra property with map-style or delegate access for type safety.
8. Delegate Ambiguity in Configuration Blocks
Problem:
// Ambiguous delegate in nested blocks repositories { maven { // Is 'url' from repository or project? url = uri("https://example.com/maven") // Unclear! } }
Solution:
repositories { maven { // Explicitly use 'this' if ambiguous this.url = uri("https://example.com/maven") // Or use the receiver parameter name url = uri("https://example.com/maven") // Usually clear from context } }
Why: Kotlin DSL sometimes requires explicit receiver (
this) to disambiguate nested scopes.
9. String Interpolation in Configuration
Problem:
// Variable not interpolated correctly val myVersion = "1.0" dependencies { implementation("com.example:lib:$myVersion") // OK implementation("com.example:lib:${project.version}") // project not in scope! }
Solution:
val myVersion = "1.0" val projectVersion = project.version // Capture outside if needed dependencies { implementation("com.example:lib:$myVersion") // OK implementation("com.example:lib:$projectVersion") // OK }
Why: Be aware of variable scope in configuration blocks. Capture values early if needed.
10. Dynamic Properties
Problem:
// Groovy's dynamic properties don't work project.myCustomProperty = "value" // Error in Kotlin! println(project.myCustomProperty) // Error!
Solution:
// Use extra properties extra["myCustomProperty"] = "value" println(extra["myCustomProperty"]) // Or define extensions properly abstract class MyExtension { abstract val myProperty: Property<String> } val myExt = extensions.create<MyExtension>("myExt") myExt.myProperty.set("value")
Why: Kotlin is statically typed; use
extra for dynamic properties or define proper extensions.
11. Task Container Configuration
Problem:
// Groovy uses 'all' without explicit call tasks.withType(Test) { // Error in Kotlin useJUnitPlatform() }
Solution:
// Kotlin requires type parameter and explicit methods tasks.withType<Test> { useJUnitPlatform() } // Or with configureEach (lazy) tasks.withType<Test>().configureEach { useJUnitPlatform() }
Why: Kotlin DSL uses generic type parameters (
<Test>) for type safety.
12. Creating vs Registering Tasks
Problem:
// Old Groovy pattern (eager) tasks.create("myTask") { doLast { println("Task") } }
Solution:
// Modern Kotlin pattern (lazy) tasks.register("myTask") { doLast { println("Task") } }
Why:
register is lazy (better for configuration cache), create is eager. Prefer register in modern Gradle.
Migration Checklist
When migrating from Groovy to Kotlin DSL:
- Change file extension:
→.gradle.gradle.kts - Replace single quotes with double quotes
- Add parentheses to all method calls
- Add type parameters where needed (
,<Test>
)<MavenPublication> - Use backticks for kebab-case identifiers
- Replace
withext
or delegatesextra - Use
instead oftasks.registertasks.create - Use
instead oftasks.named<Type>tasks.getByName - Replace
withproject.propertyproviders.gradleProperty - Test with configuration cache enabled
Automated Migration Tools
While manual migration is often best, these tools can help:
# IntelliJ IDEA has built-in Groovy → Kotlin conversion # Right-click build.gradle → Convert Groovy to Kotlin # Gradle also provides a migration guide # https://docs.gradle.org/current/userguide/migrating_from_groovy_to_kotlin_dsl.html
Recommended Tooling
| Tool | Purpose |
|---|---|
| Use Gradle Wrapper for version consistency |
| Initialize new projects with proper structure |
| Build scans for performance analysis |
| Enable configuration cache for faster builds |
| Enable build cache for incremental builds |
References
- Gradle Official Documentation: https://docs.gradle.org/
- Gradle Kotlin DSL Primer: https://docs.gradle.org/current/userguide/kotlin_dsl.html
- Gradle Best Practices: https://docs.gradle.org/current/userguide/authoring_maintainable_build_scripts.html
- Configuration Cache: https://docs.gradle.org/current/userguide/configuration_cache.html
- Build Cache: https://docs.gradle.org/current/userguide/build_cache.html