Java notes
Java 8 to 17 in a Multi-Module Build: Migrate Producers Without Breaking Consumers
Using `--release`, Gradle toolchains, and a bytecode gate to upgrade one module at a time
The problem: producers and consumers upgrade at different speeds
- A producer compiles with JDK 17 and publishes Java 17 class files (major version 61). A consumer on JDK 11 (which reads up to major version 55) gets
UnsupportedClassVersionError. - Gradle notices first. Each published variant declares the Java version it supports, and a consumer asking for Java 11 refuses a Java 17 variant with an "incompatible" resolution error.
- Hidden transitive dependencies or build plugins still assume Java 8 and fail on a newer toolchain.
The pattern: build with the new JDK, target the old bytecode
// build.gradle.kts (producer library)
plugins { `java-library` }
java {
toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } // compile with JDK 17
}
tasks.withType<JavaCompile>().configureEach {
options.release.set(11) // emit Java 11 bytecode and restrict APIs to Java 11
}
- It sets the class-file version, so consumers on Java 11 can load the jar.
- It limits you to the Java 11 API surface, so you cannot accidentally call a Java 17-only method. With plain
-source/-targetyou could compile against JDK 17's class library and fail at runtime on an older JVM.
The consumer side
// build.gradle.kts (consumer app)
java { toolchain { languageVersion.set(JavaLanguageVersion.of(11)) } }
dependencies { implementation(project(":producer")) } // or a published artifact
A rollout sequence that works
- Inventory consumers, including transitive ones. This step catches most surprises. Search your dependency graph, not just your team's repos.
- Unlock producers with the toolchain and
releasepattern above. Nothing changes for consumers. - Gate CI so no module accidentally publishes newer bytecode than planned (see below).
- Upgrade consumers one at a time, starting with leaf services.
- Raise
releasein producers once nobody depends on the older level. - Upgrade build plugins early. Old Gradle plugins and annotation processors are the most common blockers.
Enforce it with a bytecode gate
import java.io.DataInputStream;
import java.io.IOException;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
public final class BytecodeLevelCheck {
public static void main(String[] args) throws IOException {
String jarPath = args[0];
int maxMajor = Integer.parseInt(args[1]);
int worst = 0;
try (ZipFile zip = new ZipFile(jarPath)) {
var entries = zip.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
if (!entry.getName().endsWith(".class") || entry.getName().startsWith("META-INF/versions/")) {
continue;
}
try (var in = new DataInputStream(zip.getInputStream(entry))) {
in.readInt(); // magic 0xCAFEBABE
in.readUnsignedShort(); // minor
worst = Math.max(worst, in.readUnsignedShort()); // major
}
}
}
if (worst > maxMajor) {
System.err.println("Bytecode major " + worst + " exceeds allowed " + maxMajor);
System.exit(1);
}
System.out.println("OK: highest major version " + worst);
}
}
Reproducing the problem in a toy project
Failure modes to expect
- A dependency compiled for Java 8 that uses internal APIs removed in later JDKs.
- Build plugins that read JDK internals reflectively and break on newer versions (look for
InaccessibleObjectException). - Containers and JVM flags: some legacy GC flags are removed or ignored after Java 8, so audit your startup options.
What you gain
Takeaways
- Separate the compile JDK from the bytecode target with a toolchain plus
options.release. - Inventory transitive consumers first, because the graph decides your pace.
- Add a bytecode gate to CI so a policy becomes a failing build.