Migrating between language or tooling versions is universally recognized as a tricky thing to do. I recently wrapped up a project to migrate a legacy application from Java 8 to a newer Java LTS version. Now… don’t go running for the hills, I said, recently wrapped up.
This was an exciting and challenging modernization effort that involved:
- Java language changes
- Gradle build updates
- Dependency upgrades
- Strategic code improvements
- Performance and memory tuning
The plan was simple: update Java and Gradle, update the dependencies, make thoughtful use of new LTS features, and avoid turning the whole thing into a dumpster fire.
Simple plan. Real-world complexity.
Step 1: Get the Application Building and Running on the New Java LTS and Gradle Versions
The first objective was straightforward in theory: make the application compile and build against the new Java LTS and upgraded Gradle version.
In practice, it looked more like this:
- Attempt build
- Troubleshoot build errors
- Fix build errors
- Wince
- Repeat
There were a few breaking changes between the Java LTS jump and the Gradle version bump. Because of project complexity and limitations in build-time validation, this was not a one-pass process.
Fix one error, and three more would appear from “behind” it. Multiple iterative passes were required before the build stabilized.
If you have worked on legacy Java modernization projects before, you know this is normal. Annoying, but normal.
Step 2: Updating Project Dependencies (Where the Real Fun Began)
This was by far the longest phase of the migration.
The Reality of Stale Dependencies
If you guessed that some dependencies had stopped receiving updates, you would be correct.
Fortunately, none of the unmaintained dependencies introduced breaking changes with the new Java version. That was a small mercy.
Sometimes in software modernization, you have to leave well enough alone. If it is not broken and does not introduce security or compatibility risks, rewriting or replacing it can create unnecessary scope creep.
That said, most dependencies did require upgrades. Some required switching to updated artifacts from different Gradle repositories.
The process was methodical:
- Update one dependency
- Resolve compilation or runtime issues
- Smoke test
- Repeat
Until I hit Hibernate.
Hibernate Migration: Breaking Changes and Non-Standard Architecture
Hibernate was by far the most disruptive upgrade in the entire process.
Jumping multiple major versions meant dealing with significant breaking changes. That is expected, but still painful.
Complicating matters further, this project was not built on Spring. It used a more custom, non-standard architecture. That made the Hibernate upgrade more difficult to reason about compared to typical Spring-based implementations.
There was also the javax to jakarta namespace migration. While this was not nearly as complex as the Hibernate changes, it was still a required step in modern Java LTS upgrades and needed to be addressed carefully across imports and dependencies.
Dealing with Deprecated Libraries: The Case of cglib
Another issue surfaced with cglib.
It is an older reflection-based library that has since been deprecated and does not support JDK 17+.
Fortunately, usage of cglib was limited to a few concentrated areas. Refactoring those sections was manageable and nowhere near as complex as the Hibernate migration.
Still, this highlights an important lesson in Java upgrades:
- Audit reflection and proxy libraries early
- Expect legacy tooling to break with modern LTS versions
- Plan refactoring time for deprecated libraries
Performance Issues Discovered During Testing
After completing dependency upgrades, I ran deeper integration tests.
That is when out-of-memory errors started showing up.
Not ideal.
Heap Profiling and Root Cause Analysis
I ran a heap profile and stack trace analysis. The issue turned out to be architectural.
The previous implementation used a chain of three proxies to communicate with the database. Three proxies per object. This system processes millions of objects.
Worse, the implementation was retaining all of that in memory.
Before deleting anything, I applied a principle often referred to as Chesterton’s fence: do not remove something until you understand why it exists.
After spending time understanding the proxy chain, I determined they were implemented to unify:
- Logging
- Error handling
- Transaction boundaries
All of that functionality can be handled cleanly using a Hibernate stateless session.
So we refactored:
- Removed the proxy chain
- Implemented a stateless session
- Preserved logging and transaction behavior
- Added retry logic
After rerunning memory profiling, excessive object creation was gone. No more OOM errors.
That change alone significantly improved performance and memory efficiency.
Step 3: Strategic Use of New Java LTS Features
The final step was not just compatibility, but improvement.
Once everything was stable, I strategically updated portions of the codebase to leverage new Java LTS features.
IntelliJ was incredibly helpful here. It identified suboptimal or outdated constructs and in many cases offered automatic refactoring suggestions.
Benefits included:
- Cleaner, more readable code
- Reduced boilerplate
- Improved maintainability
- In some cases, measurable performance improvements
Modernizing a legacy Java application should not only make it compatible. It should leave the codebase better than you found it.
Key Lessons from Migrating a Legacy Java Application
For teams planning a Java 8 to Java LTS migration, here are a few practical takeaways:
- Expect iterative build failures during toolchain upgrades
- Audit dependencies early, especially ORM and reflection libraries
- Plan for breaking changes in Hibernate and Jakarta migrations
- Run memory profiling, not just functional testing
- Use modernization as an opportunity to simplify architecture
- Leverage IDE tooling to improve code quality post-migration
Modernization projects are rarely glamorous. But when done thoughtfully, they improve performance, maintainability, and long-term sustainability.
Conclusion
This migration was both challenging and rewarding. Moving from Java 8 to a modern Java LTS version required careful dependency management, architectural review, performance tuning, and disciplined testing.
If you are planning a Java upgrade or modernizing a legacy application, take your time, profile your memory, respect the fence, and avoid turning it into a dumpster fire.
It is absolutely worth it in the end.
About the Author
Robby Sarvis
Senior Software Engineer
Robby is a full-stack developer at RBA with a deep passion for crafting mobile applications and enhancing user experiences. With a robust skill set that encompasses both front-end and back-end development, Robby is dedicated to leveraging technology to create solutions that exceed client expectations.
Residing in a small town in Texas, Robby enjoys a balanced life that includes his wife, children, and their charming dogs.