Topic 15.3
The Module System (JPMS)
In one line
The Java Platform Module System (JPMS, Java 9) groups packages into named modules described by a module-info.java file. A module states which modules it requires and which packages it exports; everything else is hidden, even from reflection, which gives libraries real encapsulation, reliable startup checks and small custom runtimes with jlink.
Think of it like this
Think of a block of flats. Before modules, every flat in Java's building had its doors unlocked: any neighbour could walk into any room, including the kitchen you never meant to share. With modules, each flat (a module) has a front door with a sign listing which rooms visitors may enter (exports), a list of neighbours it relies on (requires), and a separate rule for the plumber who may inspect the pipes (opens, for reflection). Rooms not listed are private, and no visitor gets in.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Module
- A named group of packages with a module-info.java file saying what it needs and what it shares.
- module-info.java
- The file that declares a module: its name, its requires, exports, opens, uses and provides lines.
- requires
- A line saying this module depends on, and can read, another module.
- exports
- A line making the public types in one package usable by code in other modules.
- opens
- A line allowing other code to use deep reflection, including private members, on a package at runtime.
- Module path
- Like the class path, but for modules: the list of folders and JARs where the JVM looks for modules.
- Unnamed module
- The module that all class-path code belongs to. It can read everything and shares everything.
- Automatic module
- An ordinary JAR placed on the module path. Java gives it a name and treats it as exporting all its packages.
- jlink
- A JDK tool that builds a small custom Java runtime containing only the modules your application uses.
Step by step
01Two modules, one hidden package
Our example has a library module com.shop.money with an API package com.shop.money and an internal package com.shop.money.internal, and an application module com.shop.app that uses the library. Each module has its own folder named after it, with module-info.java at the top.
Module names follow the reverse-domain convention of packages, usually matching the main package. They must be unique on the module path.
module com.shop.money {
exports com.shop.money; // the API: Money
// com.shop.money.internal is NOT exported: Rounding stays private to this module
}02Requiring a module
The app module says it needs the money module. Without that line, even exported packages are invisible to it: a module only sees modules it reads.
module com.shop.app {
requires com.shop.money; // read the money module (java.base is implicit)
}03Compile and run on the module path
--module-source-path tells javac the folder layout (one folder per module) and -m names the module to compile; dependencies are compiled automatically. At run time, --module-path replaces -cp, and -m module/mainclass names what to run.
At startup the JVM resolves the module graph: it starts from the root module, follows every requires, and fails immediately if a module is missing or two modules contain the same package (a split package).
04What the compiler says when you cross the line
If the app imports com.shop.money.internal.Rounding, compilation fails even though Rounding is a public class. If the app forgets requires com.shop.money, even the exported API is invisible. Both messages are precise about the reason.
05exports versus opens
exports is about compile-time and normal access: other modules can use the package's public types and their public and protected members. opens is about deep reflection at runtime: other modules may call setAccessible(true) on any member, including private ones, but can't compile against the package unless it's also exported.
Frameworks that fill private fields need opens. A typical Spring or Hibernate application opens its entity package: opens com.shop.app.model to org.hibernate.orm.core;. exports and opens can both be qualified with to, which limits them to named friends.
module com.shop.app {
requires com.shop.money;
requires transitive java.sql; // users of com.shop.app also read java.sql
exports com.shop.app.api; // public API
exports com.shop.app.spi to com.shop.plugins; // only this module may use it
opens com.shop.app.model to com.fasterxml.jackson.databind; // reflection for JSON
uses com.shop.app.spi.PricingRule; // looks up services
}06Services: uses and provides
A service is an interface with one or more implementations discovered at runtime. The consumer module declares uses PricingRule; and calls ServiceLoader.load(PricingRule.class). A provider module declares provides PricingRule with FestivalDiscount;. The consumer never names the implementation class, so the two modules stay decoupled.
On the class path the same ServiceLoader reads META-INF/services/<interface name> files instead. JDBC drivers, logging back-ends (SLF4J 2, Topic 15.6) and java.net.spi providers all use this mechanism.
import java.util.ServiceLoader;
ServiceLoader<PricingRule> rules = ServiceLoader.load(PricingRule.class);
for (PricingRule rule : rules) { // every provider on the module path
price = rule.apply(price);
}07Inspecting and slimming: jdeps and jlink
jdeps reads compiled classes and reports which modules they depend on, which is the first step when migrating an application or when picking modules for jlink. jdeps --jdk-internals app.jar lists any uses of JDK internal APIs that will break.
jlink links your modules and the JDK modules they need into a standalone runtime image with its own bin/java. Our two modules plus java.base make an image of about 43 MB on Windows; a full JDK 21 is several times larger. Smaller images mean faster container pulls and a smaller attack surface.
Try it yourself
- 1
Ask about another package
In "Which module is this class in", add
System.out.println(base.isExported("java.time"));andSystem.out.println(base.isExported("sun.nio.ch"));. Predict both (true, false), then run. - 2
Add a provider
In "A module descriptor, built in code", add
.provides("com.shop.orders.spi.PricingRule", List.of("com.shop.orders.internal.FestivalDiscount"))beforebuild(), and printd.provides(). Note thatpackagesmust contain the provider's package orbuild()throws; here it does. - 3
Build two real modules
On your own JDK, create the
com.shop.moneyandcom.shop.appfolders from the walkthrough, compile withjavac -d out --module-source-path src -m com.shop.app, and run withjava --module-path out -m com.shop.app/com.shop.app.Main. Then removerequires com.shop.money;and read the compiler error.
Code & diagrams
Your class-path code is in the unnamed module. java.base requires nothing: it is the root every other module depends on.
Expected output
Main is in a named module: false
String and List live in: java.base, java.base
java.base exports java.util: true
java.base exports jdk.internal.misc: false
java.base opens java.lang for reflection: false
my module reads java.base: true
java.base requires: []
blocked: class Main cannot access class jdk.internal.misc.Unsafe (in module java.base) because module java.base does not export jdk.internal.miscThe builder added "requires mandated java.base" by itself, exactly as javac does for every module-info.java.
Expected output
module com.shop.orders {
requires java.sql
requires mandated java.base
requires transitive com.shop.money
exports com.shop.orders.api
exports com.shop.orders.spi to [com.shop.payments]
opens com.shop.orders.model
uses com.shop.orders.spi.PricingRule
}
all packages: [com.shop.orders.api, com.shop.orders.internal, com.shop.orders.model, com.shop.orders.spi]uses java.sql.Driver is why JDBC drivers are found automatically through ServiceLoader.
$ java --describe-module java.sql
java.sql@21.0.9
exports java.sql
exports javax.sql
requires java.xml transitive
requires java.logging transitive
requires java.transaction.xa transitive
requires java.base mandated
uses java.sql.Driver// module-info.java of a Spring Boot or Jackson-based service
module com.shop.app {
requires com.shop.money;
requires com.fasterxml.jackson.databind;
exports com.shop.app.api;
opens com.shop.app.dto to com.fasterxml.jackson.databind; // Jackson may set private fields here
}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Use an internal package of another module
In com.shop.app, import com.shop.money.internal.Rounding and call it, although com.shop.money doesn't export that package.
Break #2
Forget requires
Delete requires com.shop.money; from the app's module-info.java.
Myth vs fact
Myth
Java 9 forced every application to become modular.
Fact
Class-path applications run unchanged in the unnamed module. What changed for everyone is that JDK internals became inaccessible to reflection (fully enforced from Java 17).
Myth
Modules are the same as Maven modules.
Fact
A Maven module is a build unit that produces a JAR. A JPMS module is a runtime and compile-time boundary declared by module-info.java. One Maven module usually produces one JPMS module, but neither requires the other.
Myth
exports also allows reflection on private members.
Fact
exports allows normal access to public types. Deep reflection on private members needs opens.
Myth
Modules add versions and solve dependency conflicts.
Fact
JPMS deliberately has no version selection; two versions of the same module can't both be on the module path. Choosing versions stays the build tool's job (Topic 15.4).
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Split packages (the same package in two modules) are forbidden on the module path, and the JVM refuses to start. On the class path they were silently allowed, which is a common migration blocker for old JARs such as some
javax.annotationartifacts. - ▸
The unnamed module can read all named modules, but named modules can't read the unnamed module, so a modular JAR can't depend on a class-path JAR. Automatic modules bridge the gap: they read every module, including the unnamed one.
- ▸
--add-exports,--add-opens,--add-readsand--patch-moduleare runtime and compile-time escape hatches; test runners use--patch-moduleto put test classes inside the module under test.Add-Opensin a JAR manifest works only for executable JARs launched withjava -jar. - ▸
jlinkperforms whole-program link-time steps such as stripping debug info and compressing resources, and its output is a plain runtime directory you can copy into aFROM scratch-style container image. Combine it withjdeps --print-module-depsto compute the module list for class-path applications too.
Remember this
- 1
Before Java 9,
publicmeant public to the whole world. A library with packagescom.shop.money(its API) andcom.shop.money.internal(helpers) had to make the helperspublicso its own packages could share them, and then every user could call them too. A module adds a level above packages: apublicclass is only usable outside the module if its package is exported. - 2
A module is declared in
module-info.javaat the root of its source tree.requires X;says this module reads moduleX.exports p;makes public types in packagepusable by other modules;exports p to Y;is a qualified export to named modules only.opens p;allows deep reflection (private members) on packagepat runtime, which frameworks like Hibernate and Jackson need; anopen moduleopens every package.requires transitive X;passesXon to anyone who requires you.uses/provides … with …declare services forServiceLoader. - 3
The JDK itself is split into about 70 modules. **
java.base** (java.lang,java.util,java.io,java.time, …) is required implicitly by every module; others includejava.sql,java.net.http,java.loggingandjava.desktop.java --list-modulesprints them, andjava --describe-module java.sqlshows what one exports and requires. - 4
Code on the class path still works exactly as before: it lands in the unnamed module, which reads every module and exports and opens all its packages. A plain JAR placed on the module path becomes an automatic module, named from its
Automatic-Module-Namemanifest entry or its file name, which exports everything. This is how the ecosystem migrates gradually, and why most applications today still run on the class path. - 5
What modules give you: strong encapsulation (internals are truly hidden, from both the compiler and reflection), reliable configuration (the JVM checks at startup that every required module is present, and that no two modules contain the same package, instead of failing with
NoClassDefFoundErroran hour later), and **jlink**, which builds a trimmed runtime image containing only the modules your app needs, often 40 to 60 MB instead of a full JDK of over 300 MB. - 6
The most visible effect, even for class-path applications, is that the JDK's own internals are sealed. Since Java 16,
sun.*andjdk.internal.*packages can't be reached by reflection without--add-opens, and since Java 17 there is no global switch to undo it (Topic 15.2).sun.misc.Unsaferemains reachable for now through thejdk.unsupportedmodule.
Explain it without notes
What problem does the module system solve that packages and access modifiers couldn't?
What is the difference between exports and opens?
What happens to code on the class path in a modular JDK?
What does requires transitive mean, and when should you use it?
Practice
Write a program that prints the module name of String, java.util.logging.Logger and your own Main class ("unnamed" if it has none).
Write the module-info.java for a library com.acme.cache that exposes com.acme.cache.api, hides com.acme.cache.impl, depends on java.logging, and allows Jackson to reflect on com.acme.cache.api.dto.
Using ModuleDescriptor.newModule, build a module named demo that exports demo.api, and print whether build() added java.base to its requires.
Trade-offs
- ↔
Modules give real encapsulation and startup checks, but every dependency must be modular or automatic, and reflection-heavy frameworks need opens declarations.
- ↔
jlink images are small and self-contained, but each image is tied to one platform and must be rebuilt for JDK security updates.
- ↔
Staying on the class path is simpler and what most frameworks expect, but you give up encapsulation of your own internals and get no startup check for missing dependencies.
Done when you can
Done when you can write a module-info.java with requires, exports, opens and requires transitive.
Done when you can compile and run modules with --module-source-path and --module-path.
Done when you can explain the unnamed and automatic modules and why class-path apps still work.
Done when you can explain exports versus opens and read the compiler's "not visible" errors.
Done when you know what jdeps, jlink and ServiceLoader with uses/provides are for.