Core Java

Java Platform Module System: A Practical Migration Guide

The Java Platform Module System, introduced in Java 9, addresses fundamental architectural problems that have plagued large-scale Java applications for years. Before JPMS, the classpath mechanism offered no real boundaries between components, leading to tangled dependencies and accidental coupling that became harder to untangle as codebases grew.

1. The Problem JPMS Solves

Traditional Java applications suffer from what developers call “JAR hell”—a situation where the classpath loads everything indiscriminately. Your application can access internal implementation classes from libraries you depend on, creating fragile connections that break when those libraries update. There’s no way to enforce that certain packages remain private to a component.

JPMS changes this by introducing explicit module boundaries. Developers can now express that packages cannot be seen by other modules, effectively hiding packages within a module. This means your internal implementation details stay internal, not by convention or documentation, but by enforcement at the JVM level.

The system also tackles the JDK’s monolithic structure. Before modules, you had to ship the entire JDK runtime with your application, even if you only needed a fraction of its capabilities. The modular JDK breaks the monolith into separate modules, each self-contained with related classes and resources.

2. Understanding Module Basics

A module is defined through a module-info.java file at the root of your source hierarchy. This descriptor declares two critical things: what your module needs from others, and what it makes available to the outside world.

Here’s a basic module declaration:

module com.example.services {
    requires java.sql;
    requires com.example.utils;
    
    exports com.example.services.api;
}

The requires directive lists dependencies explicitly. The exports directive controls which packages other modules can access. Everything else in your module remains hidden, truly encapsulated at the JVM level.

By default, all modules depend on java.base, which serves as the foundation module. You don’t need to declare this dependency explicitly.

3. Migration Strategies for Legacy Applications

3.1 The Bottom-Up Approach

Start by converting leaf dependencies—libraries with no internal dependencies. These become explicit modules first, then work your way up the dependency tree. This strategy works well when you control most of your dependency stack and want gradual, controlled migration.

The advantage is safety. Each module you convert is tested in isolation before moving to the next. The downside is time—large applications can take months to fully modularize this way.

3.2 The Top-Down Approach

Convert your application layer to modules first, treating third-party libraries as automatic modules. When you place a traditional JAR on the module path instead of the classpath, the module system generates an automatic module from it. The module name derives from the JAR filename, and automatic modules get full read access to every other module loaded by the path.

This approach lets you start writing modular code immediately without waiting for your dependencies to migrate. The risk is that automatic modules don’t enforce the same encapsulation guarantees as explicit modules.

3.3 The Hybrid Strategy

Most real migrations use a hybrid approach. Keep legacy code on the classpath while incrementally moving components to the module path. Java 9+ maintains backward compatibility—code on the classpath lands in the “unnamed module,” which can read all other modules but doesn’t export anything specific.

This means you can run mixed environments where some code is fully modular while other parts remain traditional. The unnamed module acts as a compatibility bridge during transition.

4. Practical Migration Steps

4.1 Assessment Phase

Before touching code, understand what you have. The jdeps tool analyzes dependencies and identifies which modules your code requires. Run it against your JARs to generate a dependency report:

jdeps --module-path mods -s myapp.jar

This reveals hidden dependencies on internal JDK APIs, which is important because some internal APIs were encapsulated in Java 9. Code using sun.misc.Unsafe or similar internal classes needs refactoring or explicit --add-exports flags.

4.2 Incremental Conversion

Create module descriptors for individual components. Start with modules that have clear boundaries and minimal external dependencies. A typical service module might look like:

module com.company.invoice {
    requires com.company.common;
    requires java.logging;
    
    exports com.company.invoice.api;
    // Internal packages remain hidden
}

Test thoroughly. Module boundaries are enforced at compile time and runtime, so violations that were silent before now cause errors. This is good—it surfaces architectural problems that were previously hidden.

4.3 Handling Third-Party Dependencies

Not all libraries have migrated to modules. Your options:

  1. Use automatic modules—drop the JAR on the module path
  2. Wait for updated versions with proper module descriptors
  3. Create your own module descriptor using jar --update
  4. Keep non-modular dependencies on the classpath

The fourth option often makes sense for libraries that rarely change. Don’t force modularity where it doesn’t add value.

5. Common Pitfalls

Split Packages: Multiple modules cannot export the same package. If two JARs contain classes in com.example.util, they can’t both be explicit modules. Refactor to eliminate the overlap or use the classpath for one of them.

Reflection Issues: Strong encapsulation affects reflection. If your framework needs to access private members via reflection, modules must explicitly open packages:

module com.example.data {
    opens com.example.data.entities to hibernate.core;
}

The opens directive grants reflective access while maintaining compile-time encapsulation.

Circular Dependencies: Modules cannot have circular dependencies. If module A requires B and B requires A, you’ve found a design problem. Extract shared functionality into a third module, or rethink your boundaries.

6. When Not to Modularize

Not every project needs modules. Small applications with a handful of dependencies gain little from the overhead. Internal tools with short lifespans aren’t worth the migration effort.

Libraries face pressure to modularize because consumers expect it, but applications have more flexibility. If your codebase is stable, runs on Java 8, and has no architectural issues, migration may not justify the cost.

That said, new projects should consider modules from the start. Defining boundaries early prevents the tight coupling that makes future refactoring painful.

7. Build Tool Integration

Maven and Gradle both support modules. Maven uses the standard directory structure with module-info.java at src/main/java. The compiler plugin automatically recognizes module descriptors:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.11.0</version>
    <configuration>
        <release>17</release>
    </configuration>
</plugin>

Gradle requires explicit configuration but follows similar patterns. Both tools can generate modular JARs and handle the module path transparently.

8. Performance Considerations

The compiler loads faster because it knows exactly what your module depends on, eliminating the need to load every class LinkedIn. Class loading also improves since the module system knows which module exports which package.

The optional link-time phase can optimize further. Using jlink, you create custom runtime images containing only the modules your application uses. This reduces distribution size significantly—a minimal application might need only 30MB instead of the full JDK.

9. What We’ve Learned

The Java Platform Module System addresses real architectural problems in large codebases. It enforces encapsulation at the JVM level, makes dependencies explicit, and allows the JDK itself to be more modular and efficient.

Migration isn’t trivial, particularly for legacy applications with complex dependency graphs. The key is approaching it strategically—whether bottom-up, top-down, or hybrid—rather than attempting a big-bang conversion. Tools like jdeps help assess what needs changing, while automatic modules provide a bridge between old and new worlds.

The system shines for new projects where you can establish clean boundaries from day one. For existing applications, the cost-benefit calculation depends on your specific situation. If tight coupling and dependency management are causing pain, modules offer a structured solution. If your current architecture works and you’re not experiencing these problems, the migration effort may not be worthwhile.

Strong encapsulation, explicit dependencies, and better compile-time checking—these are JPMS’s core value propositions. Whether they matter for your project depends on its scale, complexity, and how much control you need over your architecture.

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