Migrating a Legacy Java Codebase — A Practical Strategy That Minimizes Risk
by Eric Hanson, Backend Developer at Clean Systems Consulting
The LTS hop strategy — not a single jump
Migrating from Java 8 to Java 21 as a single step is the approach most likely to fail. The gap is thirteen years of changes — removed APIs, changed defaults, module system restrictions, deprecated features that became errors. The surface area of potential breakage is too large to diagnose and fix in a single effort.
The correct path: migrate one LTS version at a time. Java 8 → 11 → 17 → 21. Each hop is a contained unit of risk: a known set of breaking changes, a known set of dependency requirements, and a clear validation gate before moving to the next hop.
Why LTS to LTS. LTS releases (8, 11, 17, 21) have multi-year support windows, meaning your dependencies are more likely to have compatible versions. Non-LTS releases (9, 10, 12–16, 18–20) are stepping stones — feature previews, not stabilization targets. Migrating to Java 10 produces a codebase that needs to migrate again immediately.
The validation gate. The codebase compiles and all tests pass on the new Java version before declaring the hop complete. Deployments to production before validating with the new runtime version are premature. The gate is not negotiable — skipping it means carrying unknown breakage forward.
Before you start: the baseline you need
Three things must be in place before the first hop:
A passing test suite. Not 100% coverage — a test suite that verifies the behavior you're migrating. If critical paths are untested, add characterization tests before migrating. A characterization test documents current behavior; it's correct by definition if it passes against the current code. It becomes a regression detector when it fails against the migrated code.
A dependency inventory. Generate a bill of materials for all direct and transitive dependencies. Maven's dependency:tree or Gradle's dependencies task produces this. For each dependency, identify:
- The current version
- Whether a Java 11/17/21 compatible version exists
- Whether the library is still maintained
Libraries abandoned before 2018 frequently have no Java 11+ compatible release. Plan to replace them before migrating.
A build that runs on the target Java version. Configure CI to build and test on both the current and target Java versions before making any code changes. Compilation failures and test failures on the target version are the inventory of work to do.
Java 8 → 11: the hardest hop
This is the most disruptive migration in the LTS sequence. Java 9 introduced the module system (JPMS), which enforces boundaries that Java 8 code routinely violated. Even without adopting modules yourself, the module system changes how the JVM loads classes:
Strong encapsulation of JDK internals. Code that accessed sun.*, com.sun.*, or internal JDK classes directly breaks. These were never public API, but Java 8 allowed access. Java 11 restricts it:
InaccessibleObjectException: Unable to make field private final int[] java.util.regex.Pattern$BmpCharProperty accessible
Libraries that used reflection to access JDK internals — serialization frameworks, bytecode manipulation (ASM, Javassist, ByteBuddy), some testing tools — were the primary offenders. Most have been updated. If you encounter this exception, update the dependency first; add --add-opens as a temporary workaround only:
--add-opens java.base/java.util.regex=ALL-UNNAMED
Removed APIs. Several APIs deprecated in Java 8 were removed in Java 9-11:
javax.xml.bind(JAXB) — moved to a separate dependency:jakarta.xml.bind-apijavax.activation— nowjakarta.activationjavax.annotation— nowjakarta.annotationsun.misc.BASE64Encoder/Decoder— usejava.util.Base64(available since Java 8)Thread.destroy(),Thread.stop()— remove usage
Add the Jakarta EE compatibility dependencies if your code uses JAXB or related APIs:
<dependency>
<groupId>jakarta.xml.bind</groupId>
<artifactId>jakarta.xml.bind-api</artifactId>
<version>4.0.0</version>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-impl</artifactId>
<version>4.0.0</version>
</dependency>
GC changes. Java 11's default GC is G1GC (not ParallelGC as in Java 8). If your application was tuned for ParallelGC, verify GC behavior under load. The defaults may require re-tuning.
PermGen is gone. Removed in Java 8 but worth noting: any -XX:PermSize or -XX:MaxPermSize JVM flags in startup scripts are no longer valid. Remove them — they'll cause startup errors in Java 11+.
Java 11 → 17: the cleaner hop
Java 17 sealed classes, records, and pattern matching are language features — opt-in improvements, not breaking changes. The disruptive changes are:
Strong encapsulation enforced by default. Java 9-15 allowed --illegal-access=permit to suppress module access warnings and permit reflective access to JDK internals. Java 17 removes --illegal-access entirely. Any remaining --illegal-access=permit flags in startup scripts cause an error. Audit your JVM flags and remove them. If the application still fails due to reflective access, the dependencies causing it must be updated.
Removed deprecated APIs:
RMI Activation(java.rmi.activation) — removedNashorn JavaScript engine— removed. Replace with GraalVM's polyglot API or a modern JavaScript engine like GraalVM JS if neededApplet API— deprecated for removal (removed in Java 17)
Security manager removal path. SecurityManager is deprecated for removal in Java 17 (removed in Java 24). If your codebase or its dependencies use SecurityManager, plan the removal.
Serialization filtering. Java 9+ introduced configurable serialization filtering (jdk.serialFilter). Java 17 enables stricter defaults. If your application uses Java serialization for data persistence or RMI, test serialization compatibility thoroughly.
Java 17 → 21: the smallest hop
The Java 17 to 21 migration has the least breaking change surface. The main considerations:
Virtual threads are production-ready. Enable them after validating the migration — they're not required but are the primary reason to target Java 21.
Pattern matching finalized. Pattern matching for switch, records, and sealed types are final features. No preview flags needed.
Deprecations to track. finalize() deprecation for removal is active. If any classes in your codebase override finalize(), replace with Cleaner or explicit resource management (AutoCloseable).
Changed defaults. Review JVM flag defaults that changed. Some G1GC tuning flags have different defaults in Java 21. Run with the same GC logging flags as before and compare behavior.
The dependency update strategy
Dependency updates are the most time-consuming part of migration. Two approaches:
Update before migrating. Update all dependencies to their latest Java-compatible versions before changing the Java version. The advantage: you're debugging one change at a time. Dependency update issues appear against the current Java version where everything else is known-good.
Update as you migrate. Migrate first, fix dependency failures as they appear. The advantage: you only update dependencies that actually break — some old versions work fine on newer Java.
The first approach is lower risk. The second is faster for large dependency graphs. In practice, a hybrid works: update major dependencies known to have compatibility issues (JAXB, logging frameworks, serialization libraries) before migrating, then address remaining failures as they appear.
Tools for dependency compatibility:
jdeps— analyzes class files for dependencies on removed APIs and internal JDK classesjdeprscan— scans for use of deprecated APIs scheduled for removal- Eclipse Migration Toolkit for Java (EMT4J) — automated analysis of migration issues across all hops
Run jdeps on your application JAR before each hop:
jdeps --jdk-internals --class-path 'libs/*' myapp.jar
Any output is a migration risk — internal API use that may break on the target version.
Keeping the application deployable throughout
The migration must not prevent deployment. A codebase stuck on an experimental branch for three months while the migration is completed is a migration that will be abandoned when a production incident requires a hotfix.
The strategy: migrate on the main branch, in increments, where each increment leaves the application in a deployable state.
Increment structure:
- Add target Java version to CI alongside current version — both must pass
- Fix compilation failures on target version — code changes only, no dependency updates yet
- Update dependencies that cause test failures
- Validate on target version — full test suite, performance tests, load tests
- Update deployment infrastructure to use target version
- Remove the old Java version from CI
Each increment is a PR that the main branch accepts. No "migration branch" that diverges for months.
Feature flags for behavior changes. When a migration requires changing application behavior — a different serialization format, a different default encoding, a different GC behavior — use a feature flag that can be rolled back without code changes. Behavior changes should be deployable independently of the Java version upgrade.
The signals that migration is complete
The migration is complete when:
- The application compiles and all tests pass on the target Java version
- No
--add-opens,--add-exports, or--illegal-accessflags remain in startup scripts (or they're documented with a deadline for removal) - All dependencies are at versions that explicitly support the target Java version
- Production has been running on the target version for at least one release cycle without regression
- The old Java version is removed from CI and build tooling
Leaving --add-opens flags as permanent configuration is technical debt that signals the migration isn't actually complete. These flags are workarounds for dependency issues that should be resolved by updating the dependency. Track them as debt and close them.