Java Platform Module System (JPMS) Internals Explained with Real-World Examples

Illustration for Java Platform Module System (JPMS) Internals Explained with Real-World Examples
By Last updated:

Many developers hit a wall when transitioning to JPMS because they assume it works exactly like the classpath. They’re often puzzled by errors like:

Error: module not found
Error: package is not visible

This happens because JPMS has a completely different internal resolution model compared to the classpath. Instead of a flat world where everything can see everything, JPMS introduces a graph of modules, dependencies, and encapsulation rules.

Understanding JPMS internals is crucial for building enterprise systems, modularizing legacy apps, and creating custom runtime images with tools like jlink.


Core JPMS Internals

At its heart, JPMS introduces several concepts that govern how code is compiled and executed.

1. Module Descriptors

Defined in module-info.java, they declare dependencies and exports.

module com.example.user {
    exports com.example.user.api;
    requires com.example.order;
}

This tells the JPMS resolver:

  • The module is named com.example.user.
  • It exports com.example.user.api.
  • It requires another module com.example.order.

2. Module Resolution Graph

Unlike the classpath (flat, global visibility), JPMS builds a directed graph of modules at startup.

  • Nodes → modules.
  • Edges → dependencies (requires).

If a dependency isn’t found, the graph build fails and the JVM won’t start the application.


3. Layers and Class Loaders

Modules are loaded in layers.

  • Boot Layer → standard JDK modules (java.base, java.sql, etc.).
  • Application Layer → user-defined modules.

Each layer has its own classloader, ensuring strict isolation.


4. Strong Encapsulation

By default, only packages explicitly exported in module-info.java are visible. Internals remain hidden. This solves the problem of accidental dependencies in large systems.

module com.example.payment {
    exports com.example.payment.api; // public
    // com.example.payment.internal is hidden
}

5. Reflection and Openness

Frameworks that rely on reflection (Spring, Hibernate) often fail unless packages are opened.

module com.example.payment {
    opens com.example.payment.entity to hibernate.core;
}
  • exports → compile-time + runtime visibility.
  • opens → runtime reflective visibility.

6. Services with ServiceLoader

JPMS integrates with the ServiceLoader mechanism for dynamic discovery.

module com.example.email {
    provides com.example.notification.api.Notifier
        with com.example.email.EmailNotifier;
}
module com.example.app {
    uses com.example.notification.api.Notifier;
}

Pitfalls in JPMS Internals

  1. Classpath fallback → Mixing classpath and module path leads to confusing errors.
  2. Split packages → Not allowed across modules.
  3. Overusing open modules → Breaks encapsulation.
  4. Transitive chaos → Overusing requires transitive creates unwanted dependencies.
  5. Non-modularized libraries → Still a challenge; need automatic or unnamed modules.

Best Practices for JPMS Internals

  • Always use explicit requires/exports.
  • Keep APIs in dedicated packages separate from internals.
  • Avoid open module in production; use opens sparingly.
  • Use jdeps to analyze dependencies and modular boundaries.
  • Prefer services (provides/uses) over hard dependencies for extensibility.

📌 What's New in Java Versions?

  • Java 5 → N/A (modules introduced in Java 9)
  • Java 9 → JPMS introduced: module descriptors, strong encapsulation, service loader integration
  • Java 11 → Refinements and tooling improvements (jdeps, IDE support)
  • Java 17 → Performance and security refinements for modular systems
  • Java 21 → No significant updates across Java versions for this feature

Analogy

Think of JPMS as a city with neighborhoods (modules):

  • Each neighborhood has gates (exports) for outsiders.
  • Some gates allow only auditors (frameworks) in (opens).
  • Roads (requires) connect neighborhoods.
  • A central city planner (resolver) checks if all roads and gates are valid before the city opens.

This prevents random construction (classpath chaos) and ensures order.


Summary + Key Takeaways

  • JPMS builds a module graph at startup.
  • Modules expose only what they declare, keeping internals hidden.
  • Reflection requires opens.
  • Services enable loose coupling with provides and uses.
  • Best practice: keep APIs clean, internals hidden, and avoid unnecessary openness.

FAQs

Q1. What is the difference between the classpath and module path?
Classpath exposes everything globally; module path enforces explicit boundaries.

Q2. Why do I get “package is not visible” errors in JPMS?
Because the package wasn’t exported in the module descriptor.

Q3. What is the purpose of requires transitive?
It re-exports dependencies so downstream modules automatically inherit them.

Q4. How do open and opens differ?

  • open module → all packages reflective.
  • opens → only specific packages reflective.

Q5. What are automatic modules, and should I use them?
They’re non-modular JARs treated as modules. Use during migration, but not long-term.

Q6. How does JPMS improve security compared to classpath?
By hiding internals, enforcing strict resolution, and limiting reflection.

Q7. When should I use jlink vs jmod?

  • jlink → Create custom runtime images.
  • jmod → Package and distribute modules.

Q8. Can I modularize incrementally?
Yes, use automatic and unnamed modules as stepping stones.

Q9. How do I handle third-party libraries that aren’t modularized?
Use as automatic modules or on the classpath temporarily.

Q10. Do frameworks like Spring and Hibernate fully support JPMS?
Partial support; many need opens for reflection.