Chapter 16Lesson 02~205 minutes

Groovy DSL and Kotlin DSL, Properties, Providers, Lazy Configuration, and Build Authoring: Guided Hands-On Workflow and Core Operations

Author the same small Gradle behavior in Kotlin and Groovy DSL, prove project-property precedence, observe Provider realization, and migrate eager task/property lookups to lazy APIs without relying on hidden user state.

PropertiesTaskProviderProvider.mapKotlin/GroovyEvidence

Author the same small Gradle behavior in Kotlin and Groovy DSL, prove project-property precedence, observe Provider realization, and migrate eager task/property lookups to lazy APIs without relying on hidden user state.

Learning objectives

  • Create Kotlin-DSL and Groovy-DSL projects that use the same Wrapper and isolated Gradle User Home.
  • Express equivalent Provider-based project properties and lazy tasks in both DSLs.
  • Prove the project-property precedence order with controlled root, user-home, environment, system, and command-line values.
  • Observe configuration, task configuration, Provider realization, and execution as separate messages.
  • Replace tasks.create/getByName and eager property lookup with register/named and Provider wiring.
  • Verify outputs and clean only disposable lab state.

1. Scenario: one Gradle engine, two DSL surfaces

You will create two tiny builds under one disposable directory: kotlin-lab/ and groovy-lab/. Both use the same already-verified Gradle 9.7.1 Wrapper files from Chapter 15 and the same isolated GRADLE_USER_HOME. This keeps the build engine constant while the DSL changes.

If you did not complete Chapter 15, bootstrap the Wrapper once with a trusted Gradle 9.7.1 installation and verify its distribution and Wrapper-JAR checksums before using it. Do not download an arbitrary Wrapper JAR.

2. Preflight: prove runtime and isolate mutable state

mkdir gradle-authoring-lab
cd gradle-authoring-lab
export GRADLE_USER_HOME="$PWD/.lab-gradle-home"

# Copy the verified Chapter 15 Wrapper files into this directory first:
# gradlew, gradlew.bat, gradle/wrapper/gradle-wrapper.jar,
# gradle/wrapper/gradle-wrapper.properties
chmod +x gradlew

./gradlew --version
java -version

Expected: Wrapper Gradle 9.7.1 and a supported runtime JVM (JDK 21 in this course baseline). The two child builds below are selected with -p; they do not need duplicate Wrapper binaries.

3. Author the Kotlin DSL build with observable lazy behavior

mkdir -p kotlin-lab
cat > kotlin-lab/settings.gradle.kts <<'EOF'
rootProject.name = "kotlin-authoring-lab"
EOF
cat > kotlin-lab/gradle.properties <<'EOF'
academyAudience=repository-default
EOF
cat > kotlin-lab/build.gradle.kts <<'EOF'
println("CONFIG: Kotlin build script evaluated")

val audience = providers.gradleProperty("academyAudience")
    .orElse("fallback-audience")

val message = audience.map {
    println("PROVIDER: Kotlin message realized")
    "hello-${it.trim()}"
}

val writeMessage = tasks.register("writeMessage") {
    println("TASK-CONFIG: Kotlin writeMessage configured")
    val outputFile = layout.buildDirectory.file("authoring/message.txt")
    outputs.file(outputFile)
    doLast {
        val file = outputFile.get().asFile
        file.parentFile.mkdirs()
        file.writeText(message.get() + System.lineSeparator())
        println("EXECUTION: wrote ${file.relativeTo(project.projectDir)}")
    }
}

tasks.register("verifyMessage") {
    dependsOn(writeMessage)
    doLast {
        check(layout.buildDirectory.file("authoring/message.txt").get().asFile.isFile)
        println("EXECUTION: verified message file")
    }
}
EOF

The top-level println belongs to configuration. The print inside the task-registration action appears only if Gradle realizes writeMessage. The print inside map appears only when the message Provider is obtained. The doLast messages are execution.

4. Run an unrelated task first: registered should remain unrealized

./gradlew -p kotlin-lab help --console=plain | tee kotlin-help.log

grep -E 'CONFIG:|TASK-CONFIG:|PROVIDER:|EXECUTION:' kotlin-help.log || true

Expected on a normal uncached run: CONFIG: Kotlin build script evaluated appears. The writeMessage configuration action, Provider transformation, and task execution messages should not be required by help. If they appear, inspect what forced realization.

5. Prove property precedence without guessing

First add a user-home value. It should override the repository-root value:

cat > "$GRADLE_USER_HOME/gradle.properties" <<'EOF'
academyAudience=user-home
EOF

./gradlew -p kotlin-lab writeMessage --console=plain
cat kotlin-lab/build/authoring/message.txt
# Expected: hello-user-home

Now test higher-priority sources one at a time:

ORG_GRADLE_PROJECT_academyAudience=environment   ./gradlew -p kotlin-lab writeMessage --rerun-tasks --console=plain
cat kotlin-lab/build/authoring/message.txt
# Expected: hello-environment

./gradlew -p kotlin-lab writeMessage --rerun-tasks   -Dorg.gradle.project.academyAudience=system --console=plain
cat kotlin-lab/build/authoring/message.txt
# Expected: hello-system

./gradlew -p kotlin-lab writeMessage --rerun-tasks   -PacademyAudience=cli --console=plain
cat kotlin-lab/build/authoring/message.txt
# Expected: hello-cli

You changed only the source of one project property. The task model and output path stayed the same; the Provider resolved a different value according to the documented priority.

6. Port the focused behavior to Groovy DSL, preserving the model

mkdir -p groovy-lab
cat > groovy-lab/settings.gradle <<'EOF'
rootProject.name = 'groovy-authoring-lab'
EOF
cat > groovy-lab/gradle.properties <<'EOF'
academyAudience=repository-default
EOF
cat > groovy-lab/build.gradle <<'EOF'
println 'CONFIG: Groovy build script evaluated'

def audience = providers.gradleProperty('academyAudience')
    .orElse('fallback-audience')

def message = audience.map {
    println 'PROVIDER: Groovy message realized'
    "hello-${it.trim()}"
}

def writeMessage = tasks.register('writeMessage') {
    println 'TASK-CONFIG: Groovy writeMessage configured'
    def outputFile = layout.buildDirectory.file('authoring/message.txt')
    outputs.file(outputFile)
    doLast {
        def file = outputFile.get().asFile
        file.parentFile.mkdirs()
        file.text = message.get() + System.lineSeparator()
        println "EXECUTION: wrote ${project.relativePath(file)}"
    }
}

tasks.register('verifyMessage') {
    dependsOn writeMessage
    doLast {
        assert layout.buildDirectory.file('authoring/message.txt').get().asFile.isFile()
        println 'EXECUTION: verified message file'
    }
}
EOF

./gradlew -p groovy-lab verifyMessage -PacademyAudience=cli --console=plain
cat groovy-lab/build/authoring/message.txt

The observable model should match the Kotlin project: same project property, same Provider transformation, same lazy registration, same task dependency, same output path. The code does not need to be textually identical to be semantically equivalent.

7. Inspect when the task and Provider are realized

./gradlew -p kotlin-lab writeMessage -PacademyAudience=trace   --rerun-tasks --console=plain | tee kotlin-write.log

grep -E 'CONFIG:|TASK-CONFIG:|PROVIDER:|EXECUTION:' kotlin-write.log

Reason about the order rather than memorizing exact unrelated log lines. Build-script evaluation is configuration. The selected task must be configured before execution. The Provider transformation is evaluated when the task action asks for the message. Finally the task writes the output. If a Provider message appears during help, something obtained it too early.

8. Engineer an eager pattern, observe the consequence, then repair it

Temporarily add this intentionally poor pattern to the Kotlin build:

// INTENTIONALLY EAGER — diagnostic exercise only.
val eagerAudience = providers.gradleProperty("academyAudience").get()
val eagerTask = tasks.create("eagerMessage") {
    doLast { println(eagerAudience) }
}

Now help must obtain academyAudience and create/configure eagerMessage even though neither is needed. If you temporarily remove every property source, help fails because get() throws for the missing value.

Repair it by returning to:

val audience = providers.gradleProperty("academyAudience")
    .orElse("fallback-audience")

val lazyTask = tasks.register("lazyMessage") {
    doLast { println(audience.get()) }
}

The fallback is explicit, and the task is merely registered until needed. If the property should be mandatory for only one task, validate it inside that task rather than making the whole build unconfigurable.

9. Optional proof: configuration cache exposes the value/timing boundary

Configuration Cache is not required for the chapter, but it is an excellent diagnostic lens. Use only the disposable lab:

./gradlew -p kotlin-lab writeMessage -PacademyAudience=cache   --configuration-cache --console=plain
./gradlew -p kotlin-lab writeMessage -PacademyAudience=cache   --configuration-cache --console=plain

On a reusable entry, Gradle can skip configuration; configuration-time messages may disappear while execution still happens or becomes up-to-date according to task inputs/outputs. Do not force Providers during configuration merely to “make the logs consistent.”

10. Challenge: choose the correct build control

A task needs DEPLOY_REGION, but only when publishPreview executes. Which design is strongest?

Answer: create val region = providers.environmentVariable("DEPLOY_REGION"), wire that Provider into a task property or obtain/validate it inside the task’s execution path. Do not call System.getenv("DEPLOY_REGION") at the top of the build script and do not print the environment for debugging.

11. Verification checklist and cleanup

  • Both Kotlin and Groovy projects ran through the same verified Wrapper.
  • help did not require writeMessage configuration or Provider realization.
  • Project-property output matched root → user-home → environment → system → CLI precedence tests.
  • The two DSLs produced the same build/authoring/message.txt behavior.
  • The eager pattern was removed after observing its configuration cost/failure coupling.
  • No real credential was stored or printed.
./gradlew --stop || true
cd ..
rm -rf gradle-authoring-lab

Only the synthetic lab and its isolated Gradle User Home are removed. Do not translate this cleanup into deleting your normal ~/.gradle.

Knowledge check

Why use -p kotlin-lab with one Wrapper?

With root and user-home academyAudience values, which one does providers.gradleProperty() resolve?

Why does tasks.create() weaken the help experiment?

Where should a mandatory property error occur if only one task needs it?

Does a Provider transformation necessarily run when the Provider is declared?

What is the evidence that the Groovy port preserved behavior?

12. Bridge to design choices

Now that both DSLs and lazy APIs have been observed rather than merely described, Lesson 3 turns the mechanisms into governance choices: which DSL to standardize, which defaults belong in the repository, when configuration avoidance matters, and when direct scripts should graduate into convention plugins or other build logic.

Official references and version notes

Version snapshot: Generated for August 24, 2026 with Gradle 9.7.1, JDK 21 as the Gradle runtime, and Java 17 as the small JVM-project target. Re-check current Gradle documentation before carrying version-sensitive recommendations into future production builds.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.