Java changed. For a long time, we built applications by throwing JAR files into a big, shared bag called the classpath. It worked, but it was messy. Dependencies were implicit, conflicts were common, and it was hard to know what code really needed to run. Then, Java 9 introduced the Java Platform Module System, or JPMS. It felt like being asked to organize a chaotic garage into a set of clearly labeled, interconnected toolboxes.
If you’re looking at your existing application and feeling overwhelmed, you’re not alone. The shift can seem large. The goal isn’t just to use modules because they’re new, but because they give us reliable structure, better security, and cleaner designs. I want to walk you through how to approach this, one practical step at a time.
The first thing you should do is not write a single line of module code. Instead, get to know your project’s current dependencies. There’s a tool for this called jdeps. It’s like an X-ray for your JAR files. Run it against your application’s main JAR or your collection of libraries. The -summary flag gives you the high-level view.
jdeps -summary my-old-app.jar
The output might surprise you. You’ll see lines like java.sql or, more importantly, java.activation or java.xml.bind. These are Java EE modules that are no longer part of the core JDK. The tool tells you exactly what you need to add to your build file. It’s a factual starting point, stripping away the guesswork. You can even ask jdeps to generate a first draft of a module-info.java file for an existing library. It’s rarely perfect, but it’s a fantastic head start.
jdeps --generate-module-info ./drafts some-third-party.jar
Now, you have a choice. Do you redesign everything at once? Almost never. The smartest way to begin is by using automatic modules. Take your existing, non-modular JAR files and simply place them on the new module path instead of the old classpath. The Java runtime will treat each JAR as an “automatic module.” It gets a name based on the JAR file (like commons-lang.jar becomes module commons.lang), it exports all its packages, and it can read every other module.
# Running with automatic modules
java --module-path "./lib/*:./myapp.jar" --module com.start/mypackage.Main
This is your bridge. Your application runs as before, but now within the module system’s awareness. You can start picking off one JAR at a time to turn into a proper, explicit module, while the rest just work as automatic modules. It’s low-risk and incremental.
When you’re ready to design your first real module, think about boundaries. What does this module do? More importantly, what does it hide? A module is defined by its module-info.java file. Let’s say I have a payment processing module.
// module-info.java for com.mypayments
module com.mypayments {
exports com.mypayments.api;
requires java.logging;
requires transitive com.common.utils;
}
The exports keyword is crucial. It declares which packages are public API. The com.mypayments.internal package, if I have one, stays hidden. The requires transitive on com.common.utils means any module that requires com.mypayments will also automatically get access to com.common.utils. This is for essential dependencies that are part of your module’s own API.
One of the most powerful patterns in the module system is using services for loose coupling. Instead of module A directly requiring the implementation in module B, it requires an interface. Module B then provides an implementation of that interface as a service. It’s like a plugin system built into the language.
Here’s how it looks. First, an API module that defines the contract.
// Module: com.myapp.api
module com.myapp.api {
exports com.myapp.api.spi;
}
// Inside that module, the interface:
package com.myapp.api.spi;
public interface DataTransformer {
String transform(String input);
}
Now, an implementation module. It needs the API, and it declares what it provides.
// Module: com.myapp.csv.transformer
module com.myapp.csv.transformer {
requires com.myapp.api; // Needs the interface
provides com.myapp.api.spi.DataTransformer
with com.myapp.csv.transformer.CsvTransformerImpl;
}
Finally, the main application module that uses the service. It doesn’t need to know about the implementation module at compile time.
// Module: com.myapp.core
module com.myapp.core {
requires com.myapp.api;
uses com.myapp.api.spi.DataTransformer; // Declares it needs this service
}
// In your application code, you find implementations like this:
ServiceLoader<DataTransformer> loader = ServiceLoader.load(DataTransformer.class);
for (DataTransformer transformer : loader) {
// Use the discovered implementation
}
This pattern is a game-changer for creating extensible applications. You can swap out implementations without changing the core module’s descriptor.
Now, let’s talk about a major migration headache: split packages. This is when the same package, like com.myapp.util, exists in two different JAR files. On the classpath, this was allowed (though risky). In the module world, it’s strictly forbidden. One package can only belong to one module.
jdeps will help you find these. The fix isn’t fun, but it’s necessary. You might have to rename one of the packages, merge the classes into a single module, or create a new, shared utility module. As a temporary band-aid during migration, you can use the --patch-module runtime flag to stitch a split package into an existing module, but you should treat this as a short-term fix, not a solution.
java --patch-module com.myapp=./patches/util.jar ...
Reflection is another area that changes. Frameworks like Spring, Hibernate, or Jackson often use reflection to access private fields or construct objects. By default, a module’s non-exported internals are completely locked away, even from reflection. You have to explicitly open them up.
Use the opens directive in your module-info.java. Be as specific as possible. Don’t just open your entire module. Open only the packages that need it, and ideally, open them only to the specific modules that need access, like your framework.
module com.myapp.persistence {
requires java.persistence;
// Open entity package ONLY to Hibernate and Jackson
opens com.myapp.persistence.entities to org.hibernate.core, com.fasterxml.jackson.databind;
// Export a public repository API
exports com.myapp.persistence.repositories;
}
This maintains a degree of control. You’re telling the system, “I know this breaks encapsulation, but I’m allowing it for this specific purpose.”
As your collection of modules grows, visualization helps. The jdeps tool can output your module dependencies in the DOT format, which you can turn into an image. Seeing a picture of your dependencies makes it easy to spot cycles—where module A depends on B, and B depends on A—which represent tight coupling you might want to address.
Once you have a set of working modules, a fantastic deployment benefit awaits: jlink. This tool lets you build a custom, stripped-down Java runtime image that contains only the modules your application actually uses. No more shipping a full JDK. The result is smaller, starts faster, and is more secure.
jlink --module-path "target/modules:$JAVA_HOME/jmods" \
--add-modules com.myapp.core \
--launcher myapp=com.myapp.core/com.myapp.Main \
--output ./myapp-runtime
You now have a ./myapp-runtime directory. Inside is a bin folder with your myapp launcher script. You can package this and ship it. It’s a complete, self-contained application. The size reduction can be dramatic.
Testing needs consideration too. Your main module might not export or open its internals, but your unit tests need access. A common approach is to have a separate module descriptor for your test sources, or to use the --patch-module option at test runtime to blend test classes into the main module for that specific execution. Modern build tools like Maven or Gradle handle a lot of this complexity for you.
Finally, remember the hybrid world. You can run with a mix: some modules on the module path, and legacy JARs on the good old classpath (which becomes the “unnamed module”). This is the essence of incremental migration. The key rule is that explicit, named modules on the module path cannot read the unnamed module. So, you start by converting low-level, foundational libraries that have few dependencies on your own code. The higher-level, complex application JARs can stay on the classpath a bit longer.
The journey to modules is a journey toward clarity. It forces conversations about what a component truly is, what it should expose, and what it should hide. Start small. Take one well-understood library, give it a module-info.java, and see how it fits. The initial investment in analysis and restructuring pays back in maintainability, a more reliable classloading environment, and the power to build lean, focused runtime images. It’s not just a new feature; it’s a better way to think about structure.