Core Java

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:

  1. Choose the lowest-level module: Start with modules that have no dependencies on your other JARs
  2. Add module-info.java: Create a module descriptor defining its dependencies and exports
  3. Move to module path: This JAR becomes a named module
  4. 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:

  1. Place all JARs on module path: Non-modular JARs become automatic modules
  2. Start from the top: Modularize your main application module first
  3. Add module-info.java: Define dependencies using automatic module names
  4. 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 opens directives

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 PracticeDescriptionWhy It Matters
Start with Java 9+ compatibilityEnsure your application runs on Java 9+ before attempting modularizationSeparates runtime issues from module-related issues, making debugging easier
Migrate incrementallyDon’t attempt to modularize everything at once; choose bottom-up or top-down and proceed systematicallyReduces risk, allows learning from early modules, and maintains application stability
Use automatic modules strategicallyTreat automatic modules as a temporary bridge during migration, not a final destinationAutomatic modules lack proper encapsulation and should be replaced with explicit modules
Review generated descriptorsAlways review and refine module-info.java files generated by jdepsGenerated descriptors are starting points that may over-export or miss important encapsulation opportunities
Encapsulate aggressivelyOnly export packages that are truly part of your public API; keep internal packages hiddenStrong encapsulation prevents tight coupling and makes refactoring safer
Document module dependenciesMaintain clear documentation of module boundaries, responsibilities, and dependenciesHelps teams understand the module graph and make informed decisions during development
Test thoroughlyRun comprehensive unit and integration tests after each module migrationModularization changes visibility and access patterns, potentially breaking reflection-based code
Monitor for illegal accessUse --illegal-access=warn or --illegal-access=deny during testingIdentifies code that relies on internal APIs before they cause production issues
Plan for cyclic dependenciesIdentify and resolve cycles early by extracting shared functionality into separate modulesJPMS prohibits cyclic dependencies between modules, so they must be refactored
Version your modules consistentlyUse consistent versioning schemes across your module ecosystemSimplifies 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

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button