**Java Module System Migration: A Step-by-Step Guide for Large Legacy Projects**

java dev.to

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

I remember the first time I tried to make sense of the Java Module System. I had a large project built over five years, hundreds of thousands of lines of code, dozens of libraries, and the only thing I knew about modules was that they sounded like a good idea. Then I ran jdeps on my application and saw pages of warnings. I felt stupid. But over time, I learned that migrating to JPMS is not about rewriting everything. It's about taking small, careful steps. I will show you exactly how I did it, so you don't have to feel stupid.

The goal of the Java Platform Module System, introduced in Java 9, is simple: tell the JVM exactly what your code needs and what it offers. This gives you strong encapsulation – no more accidentally using internal classes from libraries. It also gives you reliable dependency configuration – no more classpath errors that only appear at runtime. But for an existing project, adopting modules can feel like tearing down a house to fix a leaky pipe. It does not have to be that way.

I start every migration with a clear map. The tool for that is jdeps. It's a command-line tool that comes with the JDK. You point it at your compiled classes or your JAR file, and it tells you which packages your code uses and where they come from. I run it like this:

jdeps -summary -recursive --module-path target/classes:lib/* myapp.jar
Enter fullscreen mode Exit fullscreen mode

The -summary gives me a compact view. The -recursive looks into all dependencies. The --module-path tells it where to find module information if some JARs already have it. The output shows a list of packages and the JARs that contain them. It also shows which packages are internal to the JDK – those are the ones that will break when you try to modularize.

I always look for two things: split packages and unnamed packages. A split package happens when two different JARs have classes in the exact same package. The module system hates that. It refuses to start if it finds a split package. I also look for sun.* or com.sun.* packages – those were never meant for public use, and modules block them by default.

When I find a split package, I have to decide which JAR is the “real” owner. Often the solution is to rename the package in one of the JARs. If the JAR is open source, I can rebuild it with a different package name. If not, I keep that JAR on the classpath (the unnamed module) while my own code uses the module path. That’s a common workaround.

I also use jdeps to generate a draft module-info.java for my application. The command:

jdeps --generate-module-info . target/classes
Enter fullscreen mode Exit fullscreen mode

This creates a module-info.java file that lists all the required modules and exported packages. It is not perfect – it sometimes misses dependencies that are only used via reflection. But it gives me a starting point. I then edit that file by hand.

Now I create the first module. I pick the smallest, least connected part of my application. Maybe it is a utility library that only uses JDK classes. I create a file module-info.java in the root of that module's source directory. It looks like this:

module com.example.utils {
    exports com.example.utils.string;
    exports com.example.utils.math;
}
Enter fullscreen mode Exit fullscreen mode

That’s it. I compile it, test it. If it works, I move on to the next module. I never try to convert the whole application in one go.

The hardest part is handling libraries that use reflection. Frameworks like Spring, Hibernate, and Jackson create objects by calling private constructors or accessing private fields. In a module, those private members are inaccessible unless you explicitly open the package. I learned this the hard way when my Spring application failed to start after I added a module.

To fix it, you have two choices. You can declare your module as an open module:

open module com.example.myapp {
    requires spring.core;
    requires spring.boot;
    requires org.hibernate.orm.core;
    exports com.example.myapp.service;
}
Enter fullscreen mode Exit fullscreen mode

This opens all packages in the module for reflection. It is simple but defeats some of the encapsulation you wanted. I prefer the second option: open only the specific packages that need reflection.

module com.example.myapp {
    requires spring.core;
    requires org.hibernate.orm.core;
    exports com.example.myapp.service;
    opens com.example.myapp.model to spring.core, org.hibernate.orm.core;
    opens com.example.myapp.config to spring.core;
}
Enter fullscreen mode Exit fullscreen mode

This gives access only to the model and config packages, keeping everything else private. Most older libraries still work if you open the right packages.

For libraries that are not modularized, I let them stay on the classpath. The classpath becomes what Java calls the unnamed module. All unnamed modules have access to all named modules (with some restrictions). My own named modules can read the unnamed module, but I cannot write requires statements for it. Instead, I add a runtime flag:

java --add-reads com.example.myapp=ALL-UNNAMED
Enter fullscreen mode Exit fullscreen mode

This is a temporary hack, but it works. Over time, as libraries release modular versions, I remove these flags and add requires statements.

I remember a project where we used Apache Commons Lang. At the time, it did not have a module-info.java. I put it on the classpath. My application module used requires commons.lang – but the JAR was not a module, so the JVM treated it as an automatic module. Automatic modules have a name derived from the JAR file name. I had to use that name in my requires statement. For example, if the JAR was commons-lang3-3.12.0.jar, the automatic module name would be commons.lang3 (hyphens become dots). That worked.

Testing is where many people give up. Your unit tests might still run on the classpath, but integration tests need to run on the module path to catch encapsulation errors. I set up my Maven or Gradle build to run certain tests with --module-path and --add-modules. Here’s how I do it with Maven Surefire:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <argLine>
            --add-opens java.base/java.lang=ALL-UNNAMED
            --add-opens com.example.myapp/com.example.myapp.model=spring.core
        </argLine>
        <modulePath>
            <path>${project.build.directory}/modules</path>
        </modulePath>
    </configuration>
</plugin>
Enter fullscreen mode Exit fullscreen mode

I also write a test that checks whether the module graph resolves correctly. That test starts the application programmatically using the module layer API:

ModuleLayer bootLayer = ModuleLayer.boot();
Configuration config = bootLayer.configuration()
    .resolve(Set.of("com.example.myapp"), 
             ModuleFinder.of(Paths.get("target/modules")));
ClassLoader scl = ClassLoader.getSystemClassLoader();
ModuleLayer layer = bootLayer.defineModulesWithOneLoader(config, scl);
Enter fullscreen mode Exit fullscreen mode

If this code throws an error, I know something is wrong with my module declarations.

One more thing: split packages are the most common showstopper. I once had two JARs that both contained classes in com.google.common. That is a split package, and the JVM refused to start. I had to remove one of the JARs (it turned out one was a repackaged version of Guava). The solution was to use a library that does not duplicate packages. If you cannot avoid it, you can merge the JARs into a single JAR using a tool like jarjar or the Maven Shade plugin. Just make sure the shaded JAR does not create new split packages with other dependencies.

I also use --add-exports when a library needs to access an internal JDK package that used to be allowed. For example, some older versions of Netty used sun.security.provider. I add:

--add-exports java.base/sun.security.provider=ALL-UNNAMED
Enter fullscreen mode Exit fullscreen mode

But I treat these flags as temporary. I try to update the library to a version that no longer needs internal access.

The whole migration can take months. I do not try to finish in a week. I break it into phases. Phase one: run jdeps, document issues, fix split packages. Phase two: module the smallest leaf module. Phase three: add reflection opens as needed. Phase four: move one or two libraries from classpath to module path. Phase five: test everything. Phase six: repeat for the next module.

I also involve the team. We have a “module shepherd” who tracks which dependencies have been modularized. We keep a file named module-migration-todo.txt that lists every JAR and its status. That file is updated during code review.

The benefits show up over time. After we modularized our core service, we found a bug where two different versions of a logging library were competing. The module system caught it at compile time. Before modules, that bug would have manifested as strange log messages or missing output at runtime. We saved hours of debugging.

Another benefit: we could finally remove some sun.misc internal imports that had been working only because of --add-opens flags. We realized those were security holes. With modules, we were forced to find proper replacements.

If you are using Java 9 or later, you are already running on a module-aware JVM. You do not have to declare modules. Your code runs in the unnamed module, which has full access to everything. But once you declare even one module, the rules change. That first module-info.java is like opening the door. It takes courage. But the steps I described – analysis, small modules, reflection opens, hybrid classpath, testing – make it manageable.

I still encounter issues. Just last month I had a library that used javax.annotation which is in the java.xml.ws.annotation module that was deprecated in Java 9 and removed in Java 11. I had to add it as a separate dependency. The module system forced me to acknowledge the dependency instead of letting it slip through.

If you are reading this and feeling overwhelmed, start with jdeps. Run it on your project today. Look at the output. Pick one package that causes the least trouble. Write a module-info.java for it. Compile. See if it works. The rest will follow. I promise.

Here is the most practical piece of advice I can give: never try to convert an entire multi-module Maven project in one commit. I did that once, and spent two days reverting. Instead, convert one module, commit it, let your CI run. If it fails, you know exactly where. That is the beauty of incremental migration.

I also keep a cheat sheet of runtime flags in a file called jvm-module-opts.txt in the project root. It has the --add-opens, --add-exports, and --add-reads flags I use during development. I keep it in version control so that every developer knows which temporary flags are active.

My final piece of advice: be kind to yourself. The module system is a big change. No one gets it perfect the first time. I still make mistakes. But every time I fix a split package or write a cleaner module-info, the architecture gets better. The next person who works on that code will thank you.

Now go run jdeps.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Source: dev.to

arrow_back Back to Tutorials