Java Platform Module System: Migration Strategies for Legacy Applications
The Java Platform Module System (JPMS), introduced in Java 9, represents one of the most significant changes to the platform. For organizations running legacy monoliths, JPMS offers a path toward better maintainability, security, and scalability through explicit modular boundaries and strong encapsulation. However, migration requires careful planning and strategy. This article provides practical guidance for successfully migrating legacy Java applications to a modular architecture.
Understanding the Problem JPMS Solves
Legacy Java monoliths typically suffer from tight coupling where components are deeply intertwined, making changes risky, and poor encapsulation where everything is globally accessible, increasing bugs. Additional challenges include slow build times requiring complete rebuilds for minor changes, and limited scalability making it difficult to deploy parts independently.
JPMS solves these by introducing explicit modular boundaries, strong encapsulation, and better dependency management. The module system allows you to group packages and tightly control what belongs to the public API, with modules explicitly declaring their dependencies rather than relying on the classpath alone.
What is a Module?
Modules are used to group packages and tightly control what packages belong to the public API. Contrary to Jar files, modules explicitly declare which modules they depend on, and what packages they export. A module declaration lives in a file called module-info.java at the root of your source hierarchy.
Here’s a basic module declaration:
module com.company.data {
requires java.sql;
requires java.logging;
exports com.company.data.api;
// Internal packages are not exported
}
The public members of exported packages will be accessible by dependent modules, while private members are inaccessible even through reflection (depending on Java version settings).
Preparing for Migration
Before diving into modularization, you need to understand your application’s structure and dependencies.
Step 1: Analyze Your Dependencies
JDeps is the Java Dependency Analysis Tool, a command-line tool that processes Java bytecode and analyzes the statically declared dependencies between classes. This tool is essential for migration planning.
Run jdeps on your application to get a dependency overview:
jdeps -s myapp.jar
This provides a summary showing which JDK modules your application depends on. For a more detailed analysis:
jdeps --class-path 'libs/*' -summary -recursive myapp.jar
Step 2: Identify JDK Internal API Usage
One of the most critical migration challenges is identifying dependencies on JDK internal APIs, which are no longer accessible in Java 9+. Use jdeps to find these problematic dependencies:
jdeps -jdkinternals myapp.jar
If you use internal APIs, jdeps may suggest replacements to help you update your code. For example, if your code uses sun.misc.BASE64Encoder, jdeps will suggest using java.util.Base64 instead.
Step 3: Create a Dependency Diagram
A useful way to understand your application is through a hierarchical diagram where dependencies are represented in a layered way. This helps identify which components can be migrated and in which order.
You can generate DOT files for visualization:
jdeps --dot-output . myapp.jar
Then visualize the resulting .dot files using tools like Graphviz or online services like webgraphviz.com.
Step 4: Identify Cyclic Dependencies
Cyclic dependencies between modules are prohibited in JPMS. A common way to solve this consists in the creation of an intermediate module which contains the shared code, so the cycle is broken.
For example, if module A depends on B, and B depends on A, you need to extract the shared functionality into a new module C that both A and B can depend on.
Migration Strategies: Top-Down vs Bottom-Up
JPMS supports two primary migration strategies, each suitable for different scenarios.
Bottom-Up Migration
In this strategy, initially all JAR files are located on the classpath. Then, following a step-by-step process, all of them can be migrated one by one to the module path.
The process:
- Choose the lowest-level module: Start with modules that have no dependencies on your other JARs
- Add module-info.java: Create a module descriptor defining its dependencies and exports
- Move to module path: This JAR becomes a named module
- Repeat upward: Continue with modules at the next level
Example progression for a three-tier application (Utils → Services → Application):
# Step 1: Modularize Utils (no internal dependencies) jdeps --generate-module-info . utils.jar # Step 2: Modularize Services (depends on Utils) jdeps --generate-module-info . services.jar # Step 3: Modularize Application (depends on both) jdeps --generate-module-info . application.jar
Bottom-up migration allows lower-level modules to enforce strong encapsulation while upper-level modules can still access both modular and non-modular dependencies during transition.
Top-Down Migration
In this strategy, initially all JAR files are located on the module path, so all non-migrated projects are treated as automatic modules. Then you choose the higher-level project in the dependencies hierarchy that has not yet been migrated.
The process:
- Place all JARs on module path: Non-modular JARs become automatic modules
- Start from the top: Modularize your main application module first
- Add module-info.java: Define dependencies using automatic module names
- Work downward: Gradually replace automatic modules with proper named modules
Top-down migration encourages careful API design at higher levels and allows immediate benefits from modularization of your main application code.
Understanding Module Types
During migration, you’ll work with three types of modules:
Named Modules (Explicit Modules)
Explicit modules follow the rules for dependencies and API defined in their module declaration (module-info.java). These are fully modular JARs with complete module descriptors.
Automatic Modules
Automatic modules are plain JARs (no module descriptor) on the module path. The module name is derived from the JAR filename or the Automatic-Module-Name entry in the MANIFEST.MF file.
Automatic modules provide a bridge during migration:
- They can read all other modules
- They export all their packages
- Other named modules can depend on them
Unnamed Module
Everything on the classpath becomes part of the unnamed module. The unnamed module can read all other modules but cannot be required by named modules. This provides backward compatibility.
Generating Module Descriptors with jdeps
The jdeps command can generate module-info.java files for specified JAR files:
# Generate module descriptors jdeps --generate-module-info output-dir myapp.jar dependency.jar # Generate as open modules (for reflection-heavy code) jdeps --generate-open-module output-dir myapp.jar
The generated module-info.java files provide a starting point, but you should review and refine them to properly encapsulate internal packages.
Handling Common Migration Challenges
Split Packages
A package cannot exist in multiple modules. If you have split packages, you need to refactor or merge to avoid having the same package spread across multiple modules.
Reflection-Heavy Frameworks
If frameworks use reflection (e.g., Spring, Hibernate), declare opens to allow access:
module com.company.data {
requires java.sql;
exports com.company.data.api;
opens com.company.data.entities to org.hibernate.core;
}
The opens directive allows reflective access to a package without exporting it to the public API.
Third-Party Libraries
Some third-party libraries may not be modularized; use automatic modules or module patches in these cases. As the ecosystem matures, more libraries are adding module support.
For libraries without modules, ensure they have an Automatic-Module-Name in their MANIFEST.MF, or contribute one upstream.
Updating Build Tools
Maven Configuration
Use the maven-compiler-plugin with –module-path and –patch-module options:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<release>17</release>
</configuration>
</plugin>
Gradle Configuration
Use the java-library plugin with modular support:
plugins {
id 'java-library'
}
java {
modularity.inferModulePath = true
}
Practical Example: Modularizing a Three-Tier Application
Let’s walk through modularizing a typical layered application with data, service, and API layers.
Step 1: Data Layer Module
// module-info.java for data layer
module com.company.app.data {
requires java.sql;
requires java.logging;
exports com.company.app.data.api;
// Internal packages not exported
}
Compile and package:
javac -d mods/com.company.app.data src/com/company/app/data/**/*.java jar --create --file mods/com.company.app.data.jar -C mods/com.company.app.data .
Step 2: Service Layer Module
// module-info.java for service layer
module com.company.app.service {
requires com.company.app.data;
requires java.logging;
exports com.company.app.service.api;
}
Compile with module path:
javac --module-path mods -d mods/com.company.app.service \
src/com/company/app/service/**/*.java
Step 3: Application Module
// module-info.java for application
module com.company.app {
requires com.company.app.service;
requires com.company.app.data;
requires java.logging;
}
Run the modular application:
java --module-path mods -m com.company.app/com.company.app.Main
Testing Your Modular Application
Run full unit and integration tests to validate modular boundaries. Testing ensures that:
- All required modules are properly declared
- Exported packages are sufficient for dependent modules
- No illegal access attempts occur
- Reflection-based frameworks work correctly with
opensdirectives
Command-Line Options for Compatibility
During migration, you may need temporary workarounds. Java provides several command-line options:
--add-exports: Export a package from one module to another--add-opens: Open a package for deep reflection--add-reads: Make one module read another--patch-module: Override or augment a module’s content
These options should be temporary solutions while you refactor your code properly.
Benefits After Migration
After successful migration, you gain stronger encapsulation where modules explicitly control APIs, better dependency management with clear module boundaries, improved security through reduced surface area, and faster startup times with optimized module graphs.
The modular JDK itself demonstrates these benefits—you can create custom runtime images containing only the modules your application needs using jlink, dramatically reducing deployment size.
Best Practices
Successfully migrating to JPMS requires following proven patterns and avoiding common pitfalls. The following best practices are derived from real-world migration experiences and represent the collective wisdom of teams who have successfully modularized large-scale Java applications. Following these guidelines will help you avoid costly mistakes and ensure a smooth transition to a modular architecture.
| Best Practice | Description | Why It Matters |
|---|---|---|
| Start with Java 9+ compatibility | Ensure your application runs on Java 9+ before attempting modularization | Separates runtime issues from module-related issues, making debugging easier |
| Migrate incrementally | Don’t attempt to modularize everything at once; choose bottom-up or top-down and proceed systematically | Reduces risk, allows learning from early modules, and maintains application stability |
| Use automatic modules strategically | Treat automatic modules as a temporary bridge during migration, not a final destination | Automatic modules lack proper encapsulation and should be replaced with explicit modules |
| Review generated descriptors | Always review and refine module-info.java files generated by jdeps | Generated descriptors are starting points that may over-export or miss important encapsulation opportunities |
| Encapsulate aggressively | Only export packages that are truly part of your public API; keep internal packages hidden | Strong encapsulation prevents tight coupling and makes refactoring safer |
| Document module dependencies | Maintain clear documentation of module boundaries, responsibilities, and dependencies | Helps teams understand the module graph and make informed decisions during development |
| Test thoroughly | Run comprehensive unit and integration tests after each module migration | Modularization changes visibility and access patterns, potentially breaking reflection-based code |
| Monitor for illegal access | Use --illegal-access=warn or --illegal-access=deny during testing | Identifies code that relies on internal APIs before they cause production issues |
| Plan for cyclic dependencies | Identify and resolve cycles early by extracting shared functionality into separate modules | JPMS prohibits cyclic dependencies between modules, so they must be refactored |
| Version your modules consistently | Use consistent versioning schemes across your module ecosystem | Simplifies dependency management and makes troubleshooting easier |
Conclusion
Migrating legacy Java monoliths to modular Java with JPMS is a significant but rewarding effort. With careful planning, refactoring, and testing, you can unlock benefits like better maintainability, performance, and security.
The key is choosing the right strategy for your application—bottom-up for libraries and components with few dependencies, top-down for applications where you want immediate benefits at the application level. Use jdeps extensively to understand your current state and guide your migration decisions.
While modularity is arguably one of the terrifying Java features of all times, and proper modules are still rare in the Java world, the benefits of strong encapsulation, explicit dependencies, and improved security make the migration effort worthwhile for large-scale applications.
Additional Resources
- JEP 261: Module System – Official specification
- Oracle Java Migration Guide – Official migration documentation
- jdeps Tool Reference – Complete jdeps documentation
- The Java Module System (Manning) – Comprehensive book on JPMS
- InfoQ: Painlessly Migrating to Java Jigsaw Modules – Real-world case study

