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.
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?
The generated BuildInfo.java under the
Gradle-generated annotation-processor directory plus successful
compilation of source that references the generated class.
Why should :processor appear on
annotationProcessor but not
runtimeClasspath?
It is build-time executable compiler tooling, not a library needed by consumers at runtime.
What class-file major version should a Java 17 target produce?
Major version 61.
If testOn17 cannot find a JDK 17 and no resolver
is configured, what is the correct result?
Record the cell as unavailable/not executed, preserve the toolchain evidence, and do not claim it passed.
Why is Kotlin/JVM optional in this lab?
The chapter can prove the core toolchain/target/processor model with Gradle core Java support; Kotlin requires an external plugin and should not be a hidden mandatory dependency.
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, andjavaToolchainsdiagnostics. - 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 legacykotlinOptions. - 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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.