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-api
  • javax.activation — now jakarta.activation
  • javax.annotation — now jakarta.annotation
  • sun.misc.BASE64Encoder/Decoder — use java.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) — removed
  • Nashorn JavaScript engine — removed. Replace with GraalVM's polyglot API or a modern JavaScript engine like GraalVM JS if needed
  • Applet 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 classes
  • jdeprscan — 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:

  1. Add target Java version to CI alongside current version — both must pass
  2. Fix compilation failures on target version — code changes only, no dependency updates yet
  3. Update dependencies that cause test failures
  4. Validate on target version — full test suite, performance tests, load tests
  5. Update deployment infrastructure to use target version
  6. 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-access flags 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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Why Backend Systems Break When Teams Ignore Architecture

Skipping proper architecture might save time today, but it costs you tomorrow. Backend systems without structure slowly turn fragile and unmaintainable.

Read more

Belgrade's Tech Scene Is Growing Fast — Its Senior Backend Talent Is Already Spoken For

Serbia's startup ecosystem has real momentum. The senior backend engineers it needs to keep growing are largely committed elsewhere.

Read more

The Real Cost of a Backend Hire in Stockholm in 2025 — And the Async Alternative

You budgeted SEK 65K a month for a backend engineer. The actual cost turned out to be closer to SEK 100K once you added everything the job listing didn't mention.

Read more

The True Cost of a Backend Engineer in Zürich — and the Async Alternative Worth Knowing

You ran the numbers on your next backend hire. Then you ran them again because you thought you'd made a mistake. You hadn't.

Read more