Chapter 23Lesson 02~285 minutes

Java Toolchains, Compiler Configuration, Annotation Processing, Kotlin/JVM, and Cross-Version Builds: Guided Hands-On Workflow and Core Operations

Build a disposable Java library with a Java 21 compiler toolchain, Java 17 release target, a local annotation processor, and explicit cross-runtime test evidence.

Gradle 9.7.1Java 17 targetAnnotation processorjavaToolchainsMatrix

Learning objectives

  • Create a disposable two-project Java build whose local annotation processor generates source during compilation.
  • Run Gradle on JDK 21 while compiling with a Java 21 toolchain and strict Java 17 release target.
  • Inspect selected toolchains, generated sources, processor/runtime dependency graphs, class-file major version, and test runtime identity.
  • Run a JDK 21 matrix cell and either run or explicitly simulate the JDK 17 cell without silently provisioning tools.
  • Explain every changed file and generated state transition.

1. Preflight and lab boundary

Create a new disposable directory. The only external dependency needed for the mandatory test fixture is JUnit 6.1.3 from Maven Central; if it is not already cached, the first run may require network access. No hosted CI, signing service, artifact repository, or toolchain download is required.

mkdir -p gradle-toolchain-lab && cd gradle-toolchain-lab
export GRADLE_USER_HOME="$PWD/.gradle-user-home"

# Copy the already-reviewed Gradle 9.7.1 wrapper files from the course bootstrap lab.
./gradlew --version
java -version
./gradlew -q javaToolchains

Expected baseline: Gradle 9.7.1 runs on the local JDK 21 environment. Record every detected JDK. If Java 17 is absent, mark the JDK-17 runtime matrix cell as unavailable now; do not add an unreviewed resolver just to satisfy the exercise.

2. Create the project graph

The root has no application code. :processor is a tiny build-time tool. :library is the artifact-under-test. A project dependency lets the library use the locally built processor without publishing anything.

mkdir -p processor/src/main/java/dev/academy/processor
mkdir -p processor/src/main/resources/META-INF/services
mkdir -p library/src/main/java/dev/academy/library
mkdir -p library/src/test/java/dev/academy/library
cat > settings.gradle.kts <<'EOF'
rootProject.name = "toolchain-lab"
include("processor", "library")
EOF
cat > processor/build.gradle.kts <<'EOF'
plugins {
    `java-library`
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

tasks.withType<JavaCompile>().configureEach {
    options.release.set(17)
}
EOF

settings.gradle.kts mutates the Gradle project graph by adding two subprojects. processor/build.gradle.kts says its own source must also compile using the Java 21 toolchain under Java 17 release rules, keeping the helper compatible with the same baseline.

3. Add a harmless local annotation processor

The annotation has source retention because it exists only to drive compilation. The processor generates one deterministic class. The service-provider file makes javac discover the processor from the annotation processor path.

package dev.academy.processor;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface GenerateBuildInfo {}
package dev.academy.processor;

import java.io.IOException;
import java.io.Writer;
import java.util.Set;
import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedSourceVersion;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.TypeElement;
import javax.tools.JavaFileObject;

@SupportedAnnotationTypes("dev.academy.processor.GenerateBuildInfo")
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public final class BuildInfoProcessor extends AbstractProcessor {
    private boolean generated;

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        if (generated || roundEnv.processingOver() || annotations.isEmpty()) return false;
        try {
            JavaFileObject file = processingEnv.getFiler().createSourceFile("dev.academy.generated.BuildInfo");
            try (Writer out = file.openWriter()) {
                out.write("package dev.academy.generated;\n"
                    + "public final class BuildInfo {\n"
                    + "  private BuildInfo() {}\n"
                    + "  public static String value() { return \"generated-by-local-processor\"; }\n"
                    + "}\n");
            }
            generated = true;
            return true;
        } catch (IOException ex) {
            throw new IllegalStateException("cannot generate BuildInfo", ex);
        }
    }
}
dev.academy.processor.BuildInfoProcessor
cat > processor/src/main/java/dev/academy/processor/GenerateBuildInfo.java <<'EOF'
package dev.academy.processor;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface GenerateBuildInfo {}
EOF
cat > processor/src/main/java/dev/academy/processor/BuildInfoProcessor.java <<'EOF'
package dev.academy.processor;

import java.io.IOException;
import java.io.Writer;
import java.util.Set;
import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedSourceVersion;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.TypeElement;
import javax.tools.JavaFileObject;

@SupportedAnnotationTypes("dev.academy.processor.GenerateBuildInfo")
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public final class BuildInfoProcessor extends AbstractProcessor {
    private boolean generated;

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        if (generated || roundEnv.processingOver() || annotations.isEmpty()) return false;
        try {
            JavaFileObject file = processingEnv.getFiler().createSourceFile("dev.academy.generated.BuildInfo");
            try (Writer out = file.openWriter()) {
                out.write("package dev.academy.generated;\n"
                    + "public final class BuildInfo {\n"
                    + "  private BuildInfo() {}\n"
                    + "  public static String value() { return \"generated-by-local-processor\"; }\n"
                    + "}\n");
            }
            generated = true;
            return true;
        } catch (IOException ex) {
            throw new IllegalStateException("cannot generate BuildInfo", ex);
        }
    }
}
EOF
printf '%s
' 'dev.academy.processor.BuildInfoProcessor'   > processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor

The processor writes through the standard Filer API, so generated files are owned by compilation rather than an arbitrary source-directory mutation. This example intentionally does not claim incremental-processor status; the goal is path isolation and observable generation, not processor optimization.

4. Configure compiler/toolchain/processor boundaries

plugins {
    `java-library`
}

repositories {
    mavenCentral()
}

java {
    toolchain {
        // Select the compiler/Javadoc/test toolchain independently of Gradle's runtime JVM.
        languageVersion = JavaLanguageVersion.of(21)
    }
}

tasks.withType<JavaCompile>().configureEach {
    // Strictly compile against Java 17 language/API/bytecode rules.
    options.release.set(17)
}

dependencies {
    // The annotation type is needed by source compilation, not at runtime.
    compileOnly(project(":processor"))
    // The processor executes inside javac and stays off runtimeClasspath.
    annotationProcessor(project(":processor"))
}

testing {
    suites {
        named<JvmTestSuite>("test") {
            useJUnitJupiter("6.1.3")
        }
    }
}

fun registerMatrixTest(name: String, version: Int) =
    tasks.register<Test>(name) {
        description = "Runs the compiled Java-17-targeted tests on JDK $version"
        testClassesDirs = sourceSets.test.get().output.classesDirs
        classpath = sourceSets.test.get().runtimeClasspath
        javaLauncher.set(javaToolchains.launcherFor {
            languageVersion = JavaLanguageVersion.of(version)
        })
        useJUnitPlatform()
        shouldRunAfter(tasks.named("test"))
    }

registerMatrixTest("testOn17", 17)
registerMatrixTest("testOn21", 21)
cat > library/build.gradle.kts <<'EOF'
plugins {
    `java-library`
}

repositories {
    mavenCentral()
}

java {
    toolchain {
        // Select the compiler/Javadoc/test toolchain independently of Gradle's runtime JVM.
        languageVersion = JavaLanguageVersion.of(21)
    }
}

tasks.withType<JavaCompile>().configureEach {
    // Strictly compile against Java 17 language/API/bytecode rules.
    options.release.set(17)
}

dependencies {
    // The annotation type is needed by source compilation, not at runtime.
    compileOnly(project(":processor"))
    // The processor executes inside javac and stays off runtimeClasspath.
    annotationProcessor(project(":processor"))
}

testing {
    suites {
        named<JvmTestSuite>("test") {
            useJUnitJupiter("6.1.3")
        }
    }
}

fun registerMatrixTest(name: String, version: Int) =
    tasks.register<Test>(name) {
        description = "Runs the compiled Java-17-targeted tests on JDK $version"
        testClassesDirs = sourceSets.test.get().output.classesDirs
        classpath = sourceSets.test.get().runtimeClasspath
        javaLauncher.set(javaToolchains.launcherFor {
            languageVersion = JavaLanguageVersion.of(version)
        })
        useJUnitPlatform()
        shouldRunAfter(tasks.named("test"))
    }

registerMatrixTest("testOn17", 17)
registerMatrixTest("testOn21", 21)
EOF

The java.toolchain block selects JDK 21 tools. options.release = 17 establishes the Java 17 compatibility contract. compileOnly exposes the annotation type during compilation, while annotationProcessor supplies executable processor code. The two matrix tasks deliberately select runtime launchers separately from compilation.

5. Add production and test source

package dev.academy.library;

import dev.academy.generated.BuildInfo;
import dev.academy.processor.GenerateBuildInfo;

@GenerateBuildInfo
public final class CompatibilityLibrary {
    private CompatibilityLibrary() {}

    public static String message(String name) {
        String safe = (name == null || name.isBlank()) ? "world" : name.strip();
        return "hello, " + safe + " / " + BuildInfo.value();
    }
}
package dev.academy.library;

import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;

class CompatibilityLibraryTest {
    @Test
    void generatedCodeParticipatesInCompiledLibrary() {
        assertEquals(
            "hello, Ada / generated-by-local-processor",
            CompatibilityLibrary.message(" Ada ")
        );
    }
}
cat > library/src/main/java/dev/academy/library/CompatibilityLibrary.java <<'EOF'
package dev.academy.library;

import dev.academy.generated.BuildInfo;
import dev.academy.processor.GenerateBuildInfo;

@GenerateBuildInfo
public final class CompatibilityLibrary {
    private CompatibilityLibrary() {}

    public static String message(String name) {
        String safe = (name == null || name.isBlank()) ? "world" : name.strip();
        return "hello, " + safe + " / " + BuildInfo.value();
    }
}
EOF
cat > library/src/test/java/dev/academy/library/CompatibilityLibraryTest.java <<'EOF'
package dev.academy.library;

import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;

class CompatibilityLibraryTest {
    @Test
    void generatedCodeParticipatesInCompiledLibrary() {
        assertEquals(
            "hello, Ada / generated-by-local-processor",
            CompatibilityLibrary.message(" Ada ")
        );
    }
}
EOF

CompatibilityLibrary references a class that does not exist in source control. The only way compilation succeeds is if the processor executes and creates dev.academy.generated.BuildInfo. That makes the generated-source side effect independently inspectable.

6. Predict before execution

Prediction Why Independent proof
compileJava uses JDK 21 The library Java toolchain requests language version 21. javaToolchains inventory plus :library:compileJava --info.
Class-file target is Java 17 options.release = 17 constrains emitted bytecode. javap -verbose should report major version 61.
A generated source appears The processor is registered on annotationProcessor and source is annotated. Inspect library/build/generated/sources/annotationProcessor/java/main.
Processor is absent at runtime It is compileOnly + annotationProcessor, not implementation. Compare annotationProcessor and runtimeClasspath reports.
JDK 21 matrix cell runs JDK 21 is the known available runtime. :library:testOn21 --info and test results.

7. Compile and inspect causality

./gradlew -g "$PWD/.gradle-user-home" clean :library:compileJava --info

# Generated source produced by javac + processor.
test -f library/build/generated/sources/annotationProcessor/java/main/dev/academy/generated/BuildInfo.java
sed -n '1,80p' library/build/generated/sources/annotationProcessor/java/main/dev/academy/generated/BuildInfo.java

# Prove the bytecode contract. Java 17 == class-file major version 61.
javap -verbose library/build/classes/java/main/dev/academy/library/CompatibilityLibrary.class   | grep 'major version'

./gradlew -g "$PWD/.gradle-user-home" :library:dependencies --configuration annotationProcessor
./gradlew -g "$PWD/.gradle-user-home" :library:dependencies --configuration runtimeClasspath

The generated source is build output, not source of truth; do not commit it. The dependency reports should show :processor on the annotation-processor path and not as an ordinary runtime dependency. If javap reports major 61, the emitted class format is compatible with Java 17 class-file rules.

8. Execute the runtime matrix without hiding unavailable cells

# Known local cell.
./gradlew -g "$PWD/.gradle-user-home" :library:testOn21 --info

# Inspect first; run only if a matching JDK 17 exists.
./gradlew -g "$PWD/.gradle-user-home" -q javaToolchains
./gradlew -g "$PWD/.gradle-user-home" :library:testOn17 --info

If JDK 17 is installed, both tasks should execute the same compiled Java-17-targeted test classes on different launchers. If JDK 17 is not installed and no resolver is configured, preserve the toolchain-selection failure and mark the cell not executed. Do not relabel it as passing. The major-version-61 inspection is useful compatibility evidence, but it is not a substitute for an actual JDK 17 runtime test when that runtime is a supported production target.

9. Optional Kotlin/JVM alignment lane

If Kotlin/JVM tooling is already available or external plugin resolution is permitted, add Kotlin 2.4.10 and align it with the same compatibility contract. This is optional so the mandatory lab remains Java-only and free of extra plugin assumptions.

import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
    kotlin("jvm") version "2.4.10"
}

java {
    toolchain { languageVersion = JavaLanguageVersion.of(21) }
    sourceCompatibility = JavaVersion.VERSION_17
    targetCompatibility = JavaVersion.VERSION_17
}

tasks.withType<JavaCompile>().configureEach {
    options.release.set(17)
}

kotlin {
    jvmToolchain(21)
    compilerOptions {
        jvmTarget.set(JvmTarget.fromTarget("17"))
    }
}

The typed compilerOptions DSL is current; do not copy older kotlinOptions.jvmTarget = "17" examples into new policy. If Java and Kotlin targets intentionally diverge, keep the default target validation at error so CI fails visibly.

10. Challenge: choose the correct control

Your CI image upgrades from JDK 21 to JDK 25, but consumers are still Java 17. Which control should remain the compatibility authority: JAVA_HOME, the toolchain language version, or options.release? A defensible answer keeps the artifact contract at release 17, chooses the compiler toolchain deliberately, and separately records the Gradle runtime JVM. Changing only the agent image is not a compatibility policy.

11. Verification and cleanup

./gradlew -g "$PWD/.gradle-user-home" :library:testOn21
./gradlew -g "$PWD/.gradle-user-home" :library:dependencies --configuration runtimeClasspath
javap -verbose library/build/classes/java/main/dev/academy/library/CompatibilityLibrary.class   | grep 'major version'

cd ..
rm -rf gradle-toolchain-lab

Cleanup removes only the disposable workspace and its isolated Gradle User Home. Never delete the normal user-wide Gradle cache or JDK installation merely to make a toolchain problem disappear.

12. Summary and bridge

You now have a reproducible compile pipeline whose JDK, release target, generated source, processor path, runtime graph, and matrix cells can all be inspected separately. Lesson 3 turns those mechanics into policy choices for CI fleets and mixed Java/Kotlin codebases.

Knowledge check

What proves that annotation processing actually ran?

Why should :processor appear on annotationProcessor but not runtimeClasspath?

What class-file major version should a Java 17 target produce?

If testOn17 cannot find a JDK 17 and no resolver is configured, what is the correct result?

Why is Kotlin/JVM optional in this lab?

Official references and version notes

  • Gradle Compatibility Matrix — Gradle 9.7.1 currently requires JVM 17–26 to run; supported toolchain compile/test versions are a separate concern.
  • Toolchains for JVM projects — Java toolchain selection, --release, vendor selection, discovery, provisioning, and javaToolchains diagnostics.
  • JavaToolchainSpec API — valid toolchain specifications and language-version/vendor semantics.
  • Gradle Java Plugin — annotationProcessor, annotation processor path isolation, generated sources, incremental annotation processing, and Java compilation behavior.
  • Building Java & JVM Projects — current guidance for toolchains, release, and legacy source/target compatibility.
  • Kotlin Gradle project configuration — Kotlin/JVM plugin 2.4.10 examples, JVM toolchain behavior, and Java/Kotlin JVM-target compatibility checks.
  • Kotlin compiler options — typed compilerOptions, JvmTarget, and the deprecation of legacy kotlinOptions.
  • Kotlin releases — Kotlin 2.4.10 is the current stable line used only in the optional Kotlin/JVM lane.

Version-sensitive behavior was rechecked against current Gradle and Kotlin primary documentation on 2026-08-24. Mandatory labs use Gradle 9.7.1 through the previously verified Wrapper, JDK 21 as the Gradle runtime and Java compiler toolchain, Java 17 as the strict --release target, JUnit 6.1.3 for the Java test fixture, and an isolated GRADLE_USER_HOME. Kotlin/JVM 2.4.10 is optional because applying it may require plugin resolution. Toolchain auto-provisioning is not assumed: an unavailable JDK cell is recorded/simulated unless the learner has deliberately configured a reviewed resolver.

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.