Definition
- Module – a named, self-describing group of packages declared in
module-info.javawith explicit dependencies and a defined public surface. - requires – declares a dependency on another module;
requires transitivere-exports it,requires staticmakes it compile-time only. - exports – makes a package visible to other modules at compile and run time; a qualified
exports ... torestricts 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
ServiceLoadermechanism 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.
// 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
}
# 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
publicmeant 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.
// 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.
// 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;
}
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.
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;
}
}
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) |
// 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.
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);
}
}
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:
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.
// Service interface in com.bank.spi module
package com.bank.spi;
import java.math.BigDecimal;
public interface FeeCalculator {
BigDecimal calculateFee(BigDecimal transactionAmount);
}
// Provider module declares the implementation
module com.bank.fees {
requires com.bank.spi;
provides com.bank.spi.FeeCalculator
with com.bank.fees.GstFeeCalculator;
}
// Consumer module loads the service
module com.bank.transfer {
requires com.bank.spi;
uses com.bank.spi.FeeCalculator;
}
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;
}
}
What about jlink for custom runtimes?
The jlink tool builds a trimmed custom runtime image containing only the modules your application requires. This shrinks Docker containers dramatically.
# 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
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.
// 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
}
// Error at runtime:
// org.hibernate.MappingException: Could not get constructor for
// com.bank.app.entity.Account
// 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.
// 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.baseis 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 theAutomatic-Module-Namemanifest 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-opensand--add-readsflags are escape hatches for migration, not permanent solutions. - Strong encapsulation of JDK internals means libraries that used
sun.*classes now need--add-opensflags, which pushed libraries toward supported APIs.
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())
);
}
}
}
Module name: com.bank.transfer
Is named: true
Exports: com.bank.transfer.api
Visual Overview
┌─────────────────────────────────────────────────────┐
│ 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.
---