Java Modules (JPMS): Basics and Migration
Executive Summary
Java modules are named, versionable units of packages with a module-info.java declaration at the root. In that file, requires names the modules it depends on, and exports names the packages other modules may use. Finally, opens names the packages reflective frameworks may reach into. Strong encapsulation is the enforcement. A public class in a non-exported package is invisible outside the module. Also, setAccessible against it fails with InaccessibleObjectException on the module path, closing the loophole the reflection article described.
Migration is gradual by design. Classpath code runs as the unnamed module with full visibility, and plain jars placed on the module path become automatic modules with manifest-derived names. Then explicit module-info files land one module at a time, typically libraries first. Escape hatches exist, –add-opens and –add-exports, for the gaps. Meanwhile, jlink turns a module graph into a trimmed custom runtime per JEP 282. In practice, modularize libraries and deployments that benefit from trimmed runtimes and enforced boundaries. Keep small services on the classpath until a reason arrives, and treat escape hatches as temporary scaffolding rather than architecture.
The Problem Modules Solve
On the classpath, every class can see every other class. For example, your service code can import the internals of a logging library, and two versions of a class can collide into the same load. Meanwhile, nothing anywhere declares which jar needs which. Here is the picture that old world paints:
CLASSPATH (the old world): MODULE PATH (JPMS):
[ jarA ] [ jarB ] [ jarC ] [ jarD ] com.imraan.library
| | | | requires java.sql
+-- everything sees everything --+ exports com.imraan.library.model
opens com.imraan.library.model
no declared dependencies
no hidden internals declared edges, hidden center
duplicate classes: first on path internal packages: invisible,
wins, silently even to reflection
Three failures fall out of the left side, and you have met them all by reputation. First, the missing dependency surfaces as ClassNotFoundException at startup rather than a declared requirement at compile time. Second, the duplicate class lets jar order silently pick a winner. Third, there is the “public by accident” API that a classpath consumer can reach because public meant visible to everyone. In contrast, Java modules declare the right side. Dependencies are explicit and verified at startup, and exports define the public surface. Everything unexported is private to the module, a guarantee the runtime enforces.
module-info.java: The Module Declaration
In practice, a module begins with one file at the source root, named module-info.java, declaring the contract. For example, here is the library practice modularized:
module com.imraan.library {
exports com.imraan.library.model; // the domain types: public API
exports com.imraan.library.service; // the use cases: public API
exports com.imraan.library.store; // persistence: public API
opens com.imraan.library.model; // allow reflective access (a future mapper)
// com.imraan.library.internal is neither exported nor opened: hidden
}
The directory shape is the packages article‘s tree with one new root file:
library/
module-info.java <-- the module's front door
com/imraan/library/model/Book.java
com/imraan/library/model/Member.java
com/imraan/library/service/LoanService.java
com/imraan/library/store/CatalogStore.java
com/imraan/library/internal/Sequence.java <-- hidden by omission
Next, read the directives as a contract in both directions. First, requires says what this module needs, and the launcher verifies the whole graph before main runs. As a result, a missing dependency fails the start with a named module instead of a random ClassNotFoundException later. Second, exports says what others may use, so public now means “public and exported”: a public class in an unexported package is unreachable from outside. Finally, opens is the reflective concession: it grants reflective access without granting compile-time visibility. That is the exact permission frameworks like Hibernate and Jackson ask for, which is why the reflection article told you frameworks “need modules to open their packages”.
Strong Encapsulation: The Runtime Holds the Line
However, the enforcement is what makes modules different from a build-time convention. On the module path, the reflection article’s setAccessible trick fails against an unexported package, with an explicit error:
// from another module, against com.imraan.library.internal.Sequence:
Field field = Sequence.class.getDeclaredField("counter");
field.setAccessible(true);
// InaccessibleObjectException: Unable to make field private int
// com.imraan.library.internal.Sequence.counter accessible:
// module com.imraan.library does not "opens com.imraan.library.internal"
// to the unnamed module
Read the message carefully, because it is the platform teaching the contract. The access is denied, and the reason names the exact directive that would permit it, and the module that owns the decision. This is the same encapsulation discipline the classes article argued for, promoted from a social norm to a runtime guarantee. It is also why the migration story matters. Code that reached into other people’s privates, or depended on accidental publicness, is exactly the code that the module path breaks first.
Then compile and run on the module path to see the machinery work end to end:
javac -d out $(find library -name "*.java")
java --module-path out --module com.imraan.library/com.imraan.library.Main
// --module-path replaces -classpath
// --module names module/main-class: the launcher verifies the graph, then runs
The Migration Path: Classpath Still Works
However, JPMS shipped with a compatibility story, and it is the part working engineers meet first. Classpath code did not break in Java 9. Everything on the classpath runs as the unnamed module, which reads every other module and is readable by automatic modules. As a result, it preserves the old free-for-all exactly where it lived. The migration path from there is staged, and the module API summary and JEP 261 describe each stage:
| Stage | Layout | Module-ness | Boundary |
|---|---|---|---|
| 1. Status quo | All jars on the classpath | Unnamed module | Everything visible: full compatibility |
| 2. Hybrid | Some jars moved to the module path | Automatic modules | Named from the jar manifest (Automatic-Module-Name), no encapsulation yet |
| 3. Explicit | Your code gets module-info.java | Named modules | requires, exports, opens enforced by the runtime |
Automatic modules are the bridge worth one more sentence. Dropping a plain jar onto the module path turns it into a module named from its manifest, with every package exported and opened. Encapsulation is off, but the jar gets a real name that other module-info files can require. That single step is how most real migrations proceed. After all, it lets your explicit module declare its dependency on a library that has not modularized yet, without waiting for the library’s maintainers.
Escape Hatches, and Their Smell
Two command-line flags patch gaps in the graph. For example, –add-opens and –add-exports open or export a package at launch time, for the case where a framework reflectively needs a package that the owning module did not open:
java --add-opens com.imraan.library/com.imraan.library.model=ALL-UNNAMED \
--module-path out --module com.myapp/com.myapp.Main
In fairness, the command is honest about what it is: an override, granted by the operator, for a boundary the module chose not to grant. However, the smell is using it permanently. Every –add-opens in a production launch script is a coupling to another module’s internals that its owners may break at any release. Instead, the durable fixes are upstream: the module adds opens, or the consumer stops reaching in. Therefore, treat the flags as scaffolding with a removal ticket attached.
jlink: Modules Pay Off at Deployment
Meanwhile, the enforcement story is the everyday half of JPMS. The deployment story is the payoff. Because the module graph is explicit and complete, jlink can compute exactly which modules the application needs. It can then resolve their dependencies transitively, and produce a custom runtime image containing only those modules, per JEP 282:
jlink --add-modules com.imraan.library \
--output my-runtime \
--launcher start=com.imraan.library/com.imraan.library.Main
my-runtime/bin/start // a directory with java plus your modules:
// tens of megabytes instead of a full JDK
The trimmed image is why CLI tools, cloud functions, and container deployments care about Java modules. A runtime that contains the JDK modules you require and nothing else starts faster, ships smaller, and surfaces its whole dependency story in one readable file. Part 7’s Maven article shows the build tooling that produces modular jars. Later, the capstone’s deployment article returns to jlink images for the final service.
How Real Systems Do This
The honest state of production is mixed. The JDK itself is fully modular (java.base, java.sql, java.net.http are modules). Also, most open-source libraries declare at least an Automatic-Module-Name so their jars work on the module path. However, most business applications still run on the classpath, because the unnamed module’s compatibility preserved them and the pain of migrating mid-sized services rarely justified the ceremony. Where modules thrive is exactly where the theory predicted. Examples are libraries with real internal packages to protect, multi-team codebases where module boundaries carry team contracts, and deployments where jlink’s trimmed runtime earns its keep.
The migration war story that taught me the shape of this change involved Hibernate, the serialization article’s neighbor in the “frameworks that reflect over your fields” family. An application moved to the module path, and Hibernate’s reflective entity access hit an unopened package. As a result, the startup failed with the InaccessibleObjectException this article showed verbatim. At first, the team’s fix was –add-opens, correct as scaffolding. However, the durable fix was one line in their own module-info, opens com.app.model. It granted reflective access to exactly the package that needed it and nothing else. The general lesson: on the module path, reflective frameworks need explicit permission, and the error message tells you precisely which directive to write.
By contrast, Spring’s classpath-first strategy is the counterpoint worth knowing. The ecosystem’s largest framework chose compatibility over enforcement for application code, while supporting modules fully for those who opt in. That is not a rejection of JPMS. Instead, it is the correct reading of its adoption curve: boundaries earn their cost where multiple owners share a platform, and ceremony loses where one team owns one service.
Decision Framework
- Are you building a library others will consume? Declare at least Automatic-Module-Name in the manifest, and consider a full module-info to protect internal packages.
- Are reflective frameworks, Hibernate, Jackson, JUnit extensions, going to touch your types? opens those packages explicitly, and let the InaccessibleObjectException guide any you missed.
- Is deployment size or startup time a first-class requirement, CLI tools, functions, containers? jlink’s trimmed runtime justifies the module graph on its own.
- Is this a single-team service on the classpath today with no pain? Stay there until a reason arrives; the unnamed module keeps compatibility complete.
- Are you migrating a mid-sized application? Stage it: automatic modules for third-party jars first, then module-info for your own top-level application module, then the libraries you own.
- Is an –add-opens flag living in your launch script? File the removal ticket: the durable fix belongs in a module-info, either yours or an upstream request.
When NOT to Use This
- Do not modularize a small service for ideology. One team, one jar, one entry point gains no boundary from a module-info. Worse, it pays an error surface that the classpath never showed it.
- Do not leave libraries as bare automatic modules forever. The automatic name is a compatibility bridge, so a library with internal packages should graduate to a real module that protects them.
- Do not convert –add-opens into architecture. An operator override that becomes load-bearing is a hidden dependency on another module’s internals. It breaks exactly when the upstream release note says “internal refactor”.
Common Mistakes
- Exporting every package: encapsulation theater, the module equivalent of getters and setters everywhere. In that case, the boundary exists on paper and protects nothing.
- Forgetting opens for reflective frameworks: the module path then rejects Hibernate or Jackson at startup with InaccessibleObjectException. Fortunately, the message names the missing directive.
- Assuming public still means visible: a public class in an unexported package is unreachable from other modules. In other words, public now needs exported beside it.
- Depending on automatic module names derived from jar filenames: the fallback name is unstable across releases. Therefore, a library you require should declare Automatic-Module-Name explicitly.
- Treating the migration as all-or-nothing: the staged path, classpath to automatic to explicit, is the design, and each step works independently.
- Reading strong encapsulation as hostility: the error messages are contracts. In fact, each one names the module, the package, and the directive that would resolve it.
Key Takeaways
- A module is a named group of packages with a contract. requires declares dependencies, exports declares the API, and opens declares reflective access, while omission keeps the rest private.
- Strong encapsulation is runtime-enforced: public in an unexported package is invisible outside the module. Also, setAccessible fails with a message that names the missing directive.
- Migration is staged by design: classpath as the unnamed module, jars on the module path as automatic modules, then explicit module-info files one at a time.
- –add-opens and –add-exports are operator overrides, correct as scaffolding and corrosive as architecture.
- jlink turns an explicit module graph into a trimmed custom runtime, the deployment payoff for libraries, CLI tools, and containers.
- Most business services still run on the classpath. That is a supported state, not a debt, until a boundary or a deployment size argues otherwise.
- Reflective frameworks need opens: Hibernate, Jackson, and JUnit extensions read that permission. Then startup errors tell you exactly which line of module-info to add.
FAQ
What is JPMS in Java?
The Java Platform Module System, introduced in Java 9 through JEP 261: named modules with explicit dependencies and exported APIs, verified at startup and enforced at runtime, including against reflection.
What is module-info.java?
The declaration file at a module’s root, containing the module’s name, its requires, its exports, and its opens. It is both the module’s public contract and the file the launcher reads to verify the whole dependency graph before running.
What does the exports keyword do in JPMS?
It names packages that other modules may use. A public class in a package that is not exported is invisible to other modules, so public now means public and exported, a stronger guarantee than the classpath ever gave.
What are automatic modules in Java?
Plain jars placed on the module path: they get a module name from the jar manifest, export and open every package, and act as a bridge that lets explicit modules depend on libraries that have not modularized yet.
Does the classpath still work with Java modules?
Yes. Classpath code runs as the unnamed module with the old free-for-all visibility, fully compatible with the module path beside it. That compatibility is the design, and it is why migration proceeds one module at a time instead of all at once.
Conclusion
In the end, Java modules turn the packages article’s boundaries from convention into guarantee: dependencies declared, APIs exported, internals hidden, and reflection granted only where the contract says so. The classpath remains a fully supported home. Meanwhile, the staged migration means you adopt enforcement where it earns its keep: libraries, shared platforms, and trimmed deployments first.
Next, the following article covers the technique modules protect against and the modern world replaced: Java serialization. It explains how the object-graph-to-bytes machinery works, the security and compatibility record that made the industry walk away, and what to use instead.
Declare what you require, export what you promise, and open what reflection must see. Everything else stays inside.
Last updated on 22 September 2026.
