Back to Java Modules (JPMS)
Q1Question 1 of 2

What is the Java Platform Module System (JPMS), and what do the module-info.java directives do?

Key Concepts
  • JPMS groups packages into named modules declared in module-info.java with explicit dependencies and a defined public surface.
  • The requires directive names dependencies, requires transitive re-exports them, and requires static makes them compile-time only.
  • The exports directive makes a package visible to other modules, while a qualified exports restricts visibility to named friends.
  • The opens directive permits deep reflection into a package at run time, which frameworks like Hibernate and Jackson need.
  • The provides and uses directives wire ServiceLoader with compiler-checked declarations, replacing META-INF/services text files.
  • At startup the module system resolves the full dependency graph, so a missing module fails the launch instead of failing hours later.
  • The jlink tool builds trimmed custom runtimes containing only the modules your application needs, shrinking containers dramatically.

Definition

JPMS turns a loose bag of packages into named modules with declared dependencies and an explicit public surface, enforced by the compiler and the JVM.
  • Module – a named, self-describing group of packages declared in module-info.java with explicit dependencies and a defined public surface.
  • requires – declares a dependency on another module; requires transitive re-exports it, requires static makes it compile-time only.
  • exports – makes a package visible to other modules at compile and run time; a qualified exports ... to restricts visibility to named modules.
  • opens – allows deep reflection (setAccessible(true)) into a package at run time, required by frameworks like Hibernate and Jackson.
  • provides ... with / uses – wires the ServiceLoader mechanism with compiler-checked declarations replacing META-INF/services files.
  • Strong encapsulation – public classes in non-exported packages are invisible to other modules, which is the headline benefit of JPMS.

Think of National Bank of India as a secure building. Before JPMS, every department's doors were unlocked and anyone could walk into the vault, the audit room, or HR and grab internal files as long as they knew the room name. JPMS is like issuing each department a signed charter that says which other departments it needs (requires), which of its own rooms the public may enter (exports), and which rooms the auditors may inspect with a special pass (opens). If the payments department says it needs the fraud department but the fraud department does not exist, the building manager refuses to open in the morning instead of discovering the problem at noon when a customer is waiting.

Understanding the Concepts

What is the Java Platform Module System?

The Java Platform Module System, introduced in Java 9 under Project Jigsaw, is a language and runtime feature that groups packages into named, self-describing modules. Each module declares what it depends on and what it exposes. The compiler and JVM enforce these declarations, so internal packages stay truly hidden and missing dependencies fail at startup instead of at runtime.

JAVA
// module-info.java at the root of the com.bank.payments module
module com.bank.payments {
    requires com.bank.audit;           // dependency
    exports com.bank.payments.api;     // public surface
    opens com.bank.payments.model to com.fasterxml.jackson.databind; // reflection
}
BASH
# Compile a modular project
javac -d out --module-source-path src $(find src -name "*.java")

# Run a modular project
java --module-path out -m com.bank.payments/com.bank.payments.Main

Why does JPMS exist?

Before Java 9 the unit of deployment was the JAR, and the classpath was a flat, unordered list. Any public class in any JAR could be used by any other code, so "public" meant "public to the entire world." There was no way to say "this package is my internal implementation." There was also the fragile-classpath problem: missing JARs were discovered lazily as NoClassDefFoundError deep into execution, and duplicate or conflicting versions silently shadowed each other.

  • Before JPMS, Rajesh's payments team at National Bank of India exposed internal helper classes because public meant visible to everyone.
  • Other teams reached into those helpers, and when the payments team tried to refactor, the entire bank's build broke.
  • JPMS lets the payments team export only the API package and hide internals behind a module boundary.
JAVA
// Before JPMS: any code could use internal helpers
import com.bank.payments.internal.ValidationHelper; // worked on classpath

// After JPMS: this import fails at compile time because the package is not exported
// error: package com.bank.payments.internal is not visible

How does module-info.java work?

A module is described by a module-info.java file at the root of its source tree, which compiles to module-info.class. The file lists directives that the compiler and JVM both enforce.

JAVA
// Full module descriptor for the bank's transfer service
module com.bank.transfer {
    // Dependencies
    requires com.bank.audit;                    // regular dependency
    requires transitive com.bank.model;         // re-exported to readers of this module
    requires static com.bank.annotations;       // compile-time only

    // Public surface
    exports com.bank.transfer.api;              // visible to all modules
    exports com.bank.transfer.spi to com.bank.reporting; // visible only to reporting

    // Reflection access
    opens com.bank.transfer.entity to org.hibernate.orm.core; // Hibernate can reflect

    // Service wiring
    provides com.bank.spi.FeeCalculator with com.bank.transfer.GstFeeCalculator;
    uses com.bank.spi.AuditSink;
}
DIAGRAM
Directory layout:
com.bank.transfer/
├── module-info.java
└── com/bank/transfer/
    ├── api/
    │   └── TransferService.java      (exported)
    ├── spi/
    │   └── FeeCalculatorSpi.java     (qualified export)
    ├── entity/
    │   └── TransferEntity.java       (opened for reflection)
    └── internal/
        └── TransferValidator.java    (encapsulated)

How do the requires, exports, and opens directives work?

The requires directive names a dependency. A special form requires transitive means that any module reading yours automatically also reads the transitive dependency. requires static means the dependency is needed at compile time but optional at run time, which is useful for annotation processors.

The exports directive makes a package part of the public API. Packages you do not export are strongly encapsulated. A qualified export like exports com.bank.payments.internal to com.bank.reporting reveals a package only to named friend modules.

The opens directive permits deep reflection. Under JPMS, setAccessible(true) on members of another module fails unless that package is opened.

JAVA
package com.bank.payments.api;

import java.math.BigDecimal;

public class TransferService {
    /**
     * Transfer money between accounts at National Bank of India.
     * This class is in an exported package, so other modules can use it.
     */
    public BigDecimal transfer(String fromAccount, String toAccount, BigDecimal amount) {
        // Delegates to internal validator (not exported)
        return amount;
    }
}
JAVA
package com.bank.payments.internal;

// This class is in a non-exported package.
// Other modules CANNOT see it, even though it is public.
public class TransferValidator {
    public boolean validate(String accountId) {
        return accountId != null && accountId.startsWith("SAV");
    }
}

How is JPMS different from the classpath?

| Feature | Classpath | JPMS |
|---|---|---|
| Encapsulation | None: all public classes visible | Strong: only exported packages visible |
| Dependency checking | Lazy: NoClassDefFoundError at runtime | Eager: validated at startup |
| Duplicate packages | Silently shadowed | Split-package rule rejects duplicates |
| Reflection | Unrestricted setAccessible | Requires opens directive |
| Custom runtimes | Full JRE required | jlink builds trimmed images |
| Unit of deployment | JAR (flat list) | Module (named, versioned, declared) |

JAVA
// On the classpath: reflection works on any class
import java.lang.reflect.Field;

public class ClasspathReflection {
    public static void main(String[] args) throws Exception {
        // This worked on the classpath but fails under JPMS
        // unless the package is opened
        Class<?> clazz = Class.forName("com.bank.payments.internal.TransferValidator");
        Field[] fields = clazz.getDeclaredFields();
        for (Field f : fields) {
            f.setAccessible(true); // throws InaccessibleObjectException under JPMS
        }
    }
}

What exceptions can occur with JPMS?

The most common exceptions are IllegalAccessError when code tries to use a non-exported package, and InaccessibleObjectException when reflection tries to access a non-opened package.

JAVA
import java.lang.reflect.Field;

public class ModuleReflectionError {
    public static void main(String[] args) throws Exception {
        // Attempting deep reflection into a non-opened package
        Class<?> clazz = Class.forName("com.bank.payments.internal.TransferValidator");
        Field field = clazz.getDeclaredField("maxAmount");

        // This throws java.lang.reflect.InaccessibleObjectException:
        // Unable to make field private java.math.BigDecimal
        // com.bank.payments.internal.TransferValidator.maxAmount
        // accessible: module com.bank.payments does not "opens
        // com.bank.payments.internal" to unnamed module
        field.setAccessible(true);
    }
}
TEXT
Exception in thread "main" java.lang.reflect.InaccessibleObjectException:
  Unable to make field private java.math.BigDecimal accessible:
  module com.bank.payments does not "opens com.bank.payments.internal"
  to unnamed module

To fix this, add opens com.bank.payments.internal; to the module descriptor, or use the command-line escape hatch:

BASH
java --add-opens com.bank.payments/com.bank.payments.internal=ALL-UNNAMED \
     -m com.bank.app/com.bank.app.Main

What about ServiceLoader with provides and uses?

The service directives wire up the ServiceLoader mechanism with compiler-checked declarations, replacing the old META-INF/services text files.

JAVA
// Service interface in com.bank.spi module
package com.bank.spi;

import java.math.BigDecimal;

public interface FeeCalculator {
    BigDecimal calculateFee(BigDecimal transactionAmount);
}
JAVA
// Provider module declares the implementation
module com.bank.fees {
    requires com.bank.spi;
    provides com.bank.spi.FeeCalculator
        with com.bank.fees.GstFeeCalculator;
}
JAVA
// Consumer module loads the service
module com.bank.transfer {
    requires com.bank.spi;
    uses com.bank.spi.FeeCalculator;
}
JAVA
package com.bank.transfer.api;

import com.bank.spi.FeeCalculator;
import java.math.BigDecimal;
import java.util.ServiceLoader;

public class TransferService {
    private final FeeCalculator feeCalculator;

    public TransferService() {
        this.feeCalculator = ServiceLoader.load(FeeCalculator.class)
                .findFirst()
                .orElseThrow(() -> new IllegalStateException("No FeeCalculator found"));
    }

    public BigDecimal transferWithFee(String from, String to, BigDecimal amount) {
        BigDecimal fee = feeCalculator.calculateFee(amount);
        BigDecimal total = amount.add(fee);
        System.out.println("Transfer " + amount + " + fee " + fee + " = " + total);
        return total;
    }
}

The jlink tool builds a trimmed custom runtime image containing only the modules your application requires. This shrinks Docker containers dramatically.

BASH
# Build a custom runtime for the transfer service
jlink --module-path out:$JAVA_HOME/jmods \
      --add-modules com.bank.transfer \
      --output transfer-runtime \
      --strip-debug \
      --compress zip-6

# The result is often 40-60 MB instead of the 300+ MB full JDK
du -sh transfer-runtime/
# 48M  transfer-runtime/

# Run from the custom runtime
transfer-runtime/bin/java -m com.bank.transfer/com.bank.transfer.Main
TEXT
Docker image sizes:
  Full JDK base:      ~340 MB
  jlink custom image:  ~48 MB
  Savings:             ~86%

What are common mistakes with JPMS?

The most common mistake is forgetting to open packages for frameworks that use reflection.

JAVA
// WRONG: Hibernate cannot reflect into entity package
module com.bank.app {
    requires org.hibernate.orm.core;
    exports com.bank.app.entity; // exports is not enough for reflection
}
TEXT
// Error at runtime:
// org.hibernate.MappingException: Could not get constructor for
// com.bank.app.entity.Account
JAVA
// CORRECT: open the package for deep reflection
module com.bank.app {
    requires org.hibernate.orm.core;
    exports com.bank.app.entity;
    opens com.bank.app.entity to org.hibernate.orm.core; // reflection allowed
}

Another mistake is the split-package problem, where two modules export the same package.

TEXT
// Error at startup:
// java.lang.module.ResolutionException:
// Modules com.bank.core and com.bank.legacy both export package
// com.bank.model to module com.bank.app

When should you adopt JPMS?

JPMS pays off most for libraries and platforms that want a small, enforced public API and hidden internals. It also pays off for applications that want small jlink runtimes or use ServiceLoader plugins. For a shared library at National Bank of India where Pankaj wants other teams to use only the API package and never touch the internals, modules are exactly right.

When should you NOT adopt JPMS?

For a typical Spring Boot microservice that runs as a fat JAR on the classpath, the cost usually outweighs the benefit. Reflective frameworks force many opens directives, some older libraries are not modular and must live as automatic modules, and the split-package rule can break assemblies that worked on the classpath. Many teams stay on the classpath and adopt JPMS selectively for shared libraries.

What should a senior engineer know?

  • The JDK itself ships as about 90 platform modules; java.base is always present and does not need a requires directive.
  • Automatic modules are JARs on the module path without a module-info.java; their name is derived from the JAR filename or the Automatic-Module-Name manifest entry.
  • The unnamed module is where classpath code lives; it can read all named modules but named modules cannot require the unnamed module.
  • The --add-opens and --add-reads flags are escape hatches for migration, not permanent solutions.
  • Strong encapsulation of JDK internals means libraries that used sun.* classes now need --add-opens flags, which pushed libraries toward supported APIs.
JAVA
package com.bank.transfer.api;

import java.math.BigDecimal;

public class TransferDemo {
    public static void main(String[] args) {
        // Print the module this class belongs to
        Module mod = TransferDemo.class.getModule();
        System.out.println("Module name: " + mod.getName());
        System.out.println("Is named:    " + mod.isNamed());

        // List all packages exported by this module
        ModuleDescriptor desc = mod.getDescriptor();
        if (desc != null) {
            desc.exports().forEach(e ->
                System.out.println("Exports: " + e.source())
            );
        }
    }
}
TEXT
Module name: com.bank.transfer
Is named:    true
Exports: com.bank.transfer.api

Visual Overview

DIAGRAM
  ┌─────────────────────────────────────────────────────┐
  │                  module-info.java                     │
  ├─────────────────────────────────────────────────────┤
  │  requires com.bank.audit           (dependency)      │
  │  requires transitive com.bank.model (re-exported)    │
  │  exports com.bank.transfer.api     (public surface)  │
  │  opens com.bank.transfer.entity    (reflection)      │
  │  provides FeeCalculator with Gst.. (service)         │
  │  uses AuditSink                    (service consumer)│
  └─────────────────────────────────────────────────────┘

  Module Graph at Startup:
  ┌──────────────┐    requires    ┌──────────────┐
  │ com.bank.app │───────────────▶│com.bank.     │
  │              │                │  transfer    │
  └──────────────┘                └──────┬───────┘
                                         │ requires
                                         ▼
                                  ┌──────────────┐
                                  │ com.bank.    │
                                  │   audit      │
                                  └──────┬───────┘
                                         │ requires
                                         ▼
                                  ┌──────────────┐
                                  │  java.base   │
                                  │ (always      │
                                  │  present)    │
                                  └──────────────┘

  Encapsulation:
  ┌───────────────────────────────────────┐
  │  module com.bank.transfer             │
  │                                       │
  │  ┌─────────────────────┐  EXPORTED    │
  │  │ com.bank.transfer   │◀── other     │
  │  │        .api         │    modules   │
  │  └─────────────────────┘    can see   │
  │                                       │
  │  ┌─────────────────────┐  OPENED      │
  │  │ com.bank.transfer   │◀── Hibernate │
  │  │      .entity        │    can       │
  │  └─────────────────────┘    reflect   │
  │                                       │
  │  ┌─────────────────────┐  HIDDEN      │
  │  │ com.bank.transfer   │  ✗ nobody    │
  │  │     .internal       │    can see   │
  │  └─────────────────────┘              │
  └───────────────────────────────────────┘

Key Takeaway

The Java Platform Module System, added in Java 9 under Project Jigsaw, is a layer above packages that groups them into named modules, each described by a module-info.java file. A module declares what it depends on with requires, what packages it makes public with exports, which packages are open for deep reflection with opens, and how it participates in the service loader mechanism with provides and uses. The two big wins are strong encapsulation, meaning a public class in a non-exported package is invisible to other modules, and reliable configuration, meaning the module graph is resolved and validated at startup so a missing dependency fails the launch instead of failing hours later in production.

In the banking work at National Bank of India the problem that modules solve directly was a shared payments library. On the old classpath, other teams reached into internal helper classes because public meant public to everyone, and when the team tried to refactor those helpers the whole bank broke. With modules Rajesh exports only the api package that holds the TransferService, keeps the internal validator package encapsulated so nobody outside can even see it, and is finally free to refactor internals safely. When the reporting team genuinely needed one internal class, Pankaj used a qualified export that reveals that package to the reporting module alone, and when Jackson needed to serialize the account model he used opens on just the model package so the framework can reflect while ordinary code still cannot.

The friction is real and worth acknowledging. Reflective frameworks force you to open many packages, some older libraries are not modular and have to be treated as automatic modules, and the split-package rule can break assemblies that worked fine on the classpath. For a shared library or platform where you want a small enforced public surface and a trimmed jlink runtime, modules are clearly worth it. For an ordinary Spring Boot microservice that runs as a fat JAR on the classpath, the cost usually outweighs the benefit.

Remember one sentence: JPMS turns a loose bag of packages into named modules with declared dependencies and an enforced public surface, so internal code stays hidden and missing dependencies fail at startup instead of in production.

---

Share
Use ← → arrow keys to navigate