← Java articles

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

Shubham Anandani··~10 min read

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/-target you 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

  1. Inventory consumers, including transitive ones. This step catches most surprises. Search your dependency graph, not just your team's repos.
  2. Unlock producers with the toolchain and release pattern above. Nothing changes for consumers.
  3. Gate CI so no module accidentally publishes newer bytecode than planned (see below).
  4. Upgrade consumers one at a time, starting with leaf services.
  5. Raise release in producers once nobody depends on the older level.
  6. 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.