Contents
- Why Modules Exist
- module-info.java — The Module Descriptor
- requires & exports
- opens & Reflection
- uses & provides — Services
- Unnamed & Automatic Modules
- Migrating to Modules
- Custom JRE Images with jlink
Before Java 9, the classpath was a flat pile of JARs. Any class in any JAR could access any public class in any other JAR — there was no way to hide internal implementation packages from outside consumers. This led to widespread accidental dependencies on internal JDK APIs like sun.misc.Unsafe and to brittle "public-but-internal" APIs that the JDK team couldn't remove without breaking users.
Modules solve three problems at once:
- Strong encapsulation: packages that are not explicitly exported are inaccessible to other modules, even if their classes are public
- Reliable configuration: a module explicitly declares every module it depends on; missing dependencies are detected at startup rather than at runtime when a class is first loaded
- Scalable platform: the JDK itself is split into modules, so jlink can create a minimal JRE containing only what your application needs
// Before modules — nothing stops this
import sun.misc.BASE64Encoder; // internal JDK class
import com.example.internal.Helper; // internal library class — should be hidden
// With modules — these imports FAIL at compile time if the packages aren't exported
// You are forced to use only the intended public API
You do not have to modularize your own application to benefit from modules. The JDK itself uses JPMS, so even classpath applications get protection against accidentally depending on internal JDK APIs (enforced via command-line flags in modern Java).
A module is defined by a file named module-info.java placed at the root of the module's source tree (the same level as the top-level package). It declares the module's name, its dependencies, and what it exposes. The module name typically follows the reverse-domain convention (matching the main package name).
// src/module-info.java (sits beside the com/ package directory)
module com.example.accounts {
// Packages this module exposes to ALL other modules
exports com.example.accounts.api;
exports com.example.accounts.model;
// Package exposed only to a specific module (qualified export)
exports com.example.accounts.internal.spi to com.example.accounts.impl;
// Modules this module depends on (compile-time and runtime)
requires java.base; // always implicit, listed for clarity
requires java.sql;
requires com.example.common;
// Compile-time-only dependency (like an annotation processor)
requires static com.example.annotations;
// Transitive: anyone requiring this module also gets java.logging
requires transitive java.logging;
}
The module source layout mirrors the module name:
// Typical layout for module com.example.accounts
src/
com.example.accounts/
module-info.java
com/
example/
accounts/
api/
AccountService.java // exported — visible outside the module
model/
Account.java // exported — visible outside the module
internal/
AccountRepository.java // NOT exported — hidden from other modules
AccountValidator.java // NOT exported
requires declares a compile-time and runtime dependency on another module. exports makes a package's public types visible to other modules. Together they form the core of the module declaration. The key insight: a package that is not exported is completely inaccessible from outside the module — even if every class in it is public.
// Module A: provides a service API + implementation
module com.example.payment {
// Only the api package is visible to consumers
exports com.example.payment.api;
// Internal packages — invisible outside this module
// com.example.payment.internal (hidden)
// com.example.payment.repository (hidden)
requires java.base;
requires java.sql;
}
// Module B: consumes Module A
module com.example.checkout {
// Declare the dependency
requires com.example.payment;
// Also exposes its own API
exports com.example.checkout.api;
requires java.base;
}
// In Module B's code:
package com.example.checkout.service;
import com.example.payment.api.PaymentProcessor; // OK — exported package
// import com.example.payment.internal.StripeClient; // COMPILE ERROR — not exported
public class CheckoutService {
private final PaymentProcessor processor;
// ...
}
The requires transitive modifier means that any module which requires yours automatically also gets the transitive dependency — useful when your API returns types from another module.
// If com.example.common exports a type used in com.example.accounts' API:
module com.example.accounts {
exports com.example.accounts.api;
// Without 'transitive', users of com.example.accounts would ALSO have to
// declare 'requires com.example.common' to use types from its API.
// With 'transitive', it's inherited automatically.
requires transitive com.example.common;
}
Reflection can bypass normal access controls — frameworks like Spring, Hibernate, and Jackson use it to inspect and manipulate private fields. Modules block this by default: even for exported packages, reflection on private members is forbidden unless the module explicitly opens the package. opens grants deep reflection access (including private members) while keeping the package's types hidden from normal code.
module com.example.persistence {
exports com.example.persistence.api; // normal access to public types
// Opens the entity package to Hibernate for reflection
opens com.example.persistence.entity to org.hibernate.orm.core;
// Opens to Jackson for JSON serialization
opens com.example.persistence.dto to com.fasterxml.jackson.databind;
// open module — opens ALL packages to reflection from any module
// Use sparingly; only appropriate for application modules, not libraries
// open module com.example.app { ... }
requires java.base;
requires java.persistence;
requires org.hibernate.orm.core;
requires com.fasterxml.jackson.databind;
}
| Directive | Normal access (public types) | Reflective access (private members) |
| exports pkg | ✅ All modules | ❌ Forbidden |
| exports pkg to M | ✅ Module M only | ❌ Forbidden |
| opens pkg | ❌ Hidden (runtime only) | ✅ All modules |
| opens pkg to M | ❌ Hidden | ✅ Module M only |
| exports + opens | ✅ All modules | ✅ All modules |
Avoid opens to all modules (opens pkg; without a target). It re-exposes your internals to reflection from any code and undermines the encapsulation you're trying to achieve. Prefer opens pkg to specificFrameworkModule.
The module system has first-class support for the Service Loader pattern — a way to decouple an API from its implementations. A module that consumes a service declares uses ServiceInterface. A module that provides an implementation declares provides ServiceInterface with ImplementationClass. At runtime, ServiceLoader.load(ServiceInterface.class) discovers all registered implementations.
// --- Module 1: API (com.example.crypto.api) ---
module com.example.crypto.api {
exports com.example.crypto.api; // exports the interface
}
// The service interface
package com.example.crypto.api;
public interface HashAlgorithm {
String name();
byte[] hash(byte[] data);
}
// --- Module 2: SHA-256 implementation (com.example.crypto.sha256) ---
module com.example.crypto.sha256 {
requires com.example.crypto.api;
provides com.example.crypto.api.HashAlgorithm
with com.example.crypto.sha256.Sha256Algorithm;
}
package com.example.crypto.sha256;
import com.example.crypto.api.HashAlgorithm;
import java.security.MessageDigest;
public class Sha256Algorithm implements HashAlgorithm {
@Override public String name() { return "SHA-256"; }
@Override public byte[] hash(byte[] data) {
try { return MessageDigest.getInstance("SHA-256").digest(data); }
catch (Exception e) { throw new RuntimeException(e); }
}
}
// --- Module 3: Consumer (com.example.app) ---
module com.example.app {
requires com.example.crypto.api;
uses com.example.crypto.api.HashAlgorithm; // declares intent to load services
}
// Consumer code — discovers all HashAlgorithm implementations at runtime
import java.util.ServiceLoader;
import com.example.crypto.api.HashAlgorithm;
ServiceLoader<HashAlgorithm> loader = ServiceLoader.load(HashAlgorithm.class);
for (HashAlgorithm algo : loader) {
System.out.println("Found algorithm: " + algo.name());
}
// Found algorithm: SHA-256
Not all JARs are modular. JPMS handles non-modular JARs in two ways:
Unnamed module: code on the classpath (not the module path) is placed in a single unnamed module. It can read all named modules, and all named modules that requires nothing special can read its packages. The unnamed module is a compatibility shim — it lets existing classpath applications work unchanged on Java 9+.
Automatic module: a plain (non-modular) JAR placed on the module path becomes an automatic module. Its name is derived from the JAR filename (hyphens become dots, version suffix stripped). It exports all its packages and requires all other modules — a broad "trust everyone" mode that enables incremental modularization.
// Placing a non-modular library on the module path makes it an automatic module.
// Example: guava-32.0.0-jre.jar → automatic module name: com.google.guava
// Your module can then declare:
module com.example.app {
requires com.google.guava; // automatic module name from MANIFEST.MF or jar filename
exports com.example.app.api;
}
// To check a JAR's automatic module name before using it:
// $ jar --describe-module --file=guava-32.0.0-jre.jar
// No module descriptor found.
// Deriving module descriptor from JAR manifest and filename.
// com.google.guava@32.0.0-jre automatic
// requires java.base mandated
// contains com.google.common.annotations
// ...
Library authors should add an Automatic-Module-Name entry to their MANIFEST.MF to declare a stable automatic module name before migrating to full JPMS. This lets consumers depend on a predictable name rather than a JAR-filename-derived one that may change between releases.
Migrating an existing application to modules is an incremental process. The recommended approach is bottom-up: modularize the leaf libraries first, then work upward toward the application code.
- Audit dependencies — run jdeps --multi-release 17 --module-path libs/ myapp.jar to see what each JAR depends on
- Check for split packages — JPMS forbids the same package appearing in two modules. Identify and merge or rename them.
- Start with automatic modules — move third-party JARs to the module path; they become automatic modules
- Add module-info.java — start with a minimal descriptor (requires your deps, exports your API packages)
- Fix encapsulation violations — the compiler will tell you which internal packages you were accidentally depending on
- Add opens as needed — for frameworks (Spring, Hibernate, Jackson) that need reflection access
// Step 1: minimal module-info.java — just enough to compile
module com.example.myapp {
requires java.base;
requires java.sql;
requires com.google.guava; // automatic module
// Start by exporting everything, tighten later
exports com.example.myapp;
exports com.example.myapp.service;
exports com.example.myapp.model;
// Allow Spring to reflect on your beans
opens com.example.myapp.service to spring.beans, spring.context;
opens com.example.myapp.model to com.fasterxml.jackson.databind;
}
// Run jdeps to discover what you're missing:
// $ jdeps --module-path mods/ --add-modules ALL-MODULE-PATH myapp.jar
Once your application and its dependencies are modular, jlink can create a self-contained, minimal JRE image containing only the JDK modules your application actually uses. A typical desktop application needs only a fraction of the full JDK — this dramatically reduces deployment size (from ~200 MB to tens of MB).
// Discover which JDK modules your app needs:
// $ jdeps --print-module-deps --module-path mods/ myapp.jar
// java.base,java.sql,java.logging
// Create a custom JRE with exactly those modules + your application:
// $ jlink \
// --module-path $JAVA_HOME/jmods:mods/ \
// --add-modules com.example.myapp,java.base,java.sql,java.logging \
// --launcher myapp=com.example.myapp/com.example.myapp.Main \
// --output dist/myapp-runtime \
// --compress=2 \
// --strip-debug \
// --no-man-pages
// Run the custom image:
// $ dist/myapp-runtime/bin/myapp
// List modules in the image:
// $ dist/myapp-runtime/bin/java --list-modules
// com.example.myapp
// java.base@21
// java.logging@21
// java.sql@21
jlink images are not universal JREs — they are platform-specific binaries for the OS and architecture you build on. Use a build matrix (e.g., GitHub Actions) to produce images for Linux, macOS, and Windows simultaneously. For container deployments, a minimal JRE image is smaller than even the slim OpenJDK Docker images.