You copied a module-info.java from a tutorial, dropped it next to your packages, and the app still started the same way. Maven still printed classpath. Nothing was encapsulated. The file compiled, so it must be doing something — but you could not say what.
That is the usual first contact with the Java Platform Module System (JPMS). Copying the file is not the same as running on the module path.
A module is a named set of packages with explicit dependencies and an explicit API surface. module-info.java is how you declare that graph. It only binds when the artifact is loaded from the module path. On the classpath it is cargo.
The file that compiles and changes nothing
Put this at the root of src/main/java and a Spring Boot app will usually behave exactly as it did yesterday:
module com.geekmonks.app {
requires spring.boot;
requires spring.context;
}
The compiler may accept it. java -jar app.jar still loads the fat JAR as the unnamed module — everything on -cp / CLASSPATH. The module-info.class inside that JAR is ignored at run time.
So the question is not “do I have a module-info.java?” It is “is this code on the module path, with exports and requires that the JVM actually enforces?”
When it shipped
| Release | Status | Spec |
|---|---|---|
| Java 9 | Standard feature | JEP 261 |
| Java 11 LTS | First LTS most teams met it on | — |
| Java 17 / 21 / 25 LTS | Same module system | — |
No preview flag, ever. Java 9 also modularized the JDK itself (JEP 200) and started encapsulating internal APIs (JEP 260). The module-info.java you write is the application-facing piece of that work.
The syntax has not grown a new meaning since. What did grow later is a language convenience for simple names — import module on Java 25 — which is not this feature. A short section at the end draws the line.
Mental model
Think of a module as a JAR that finally tells the truth:
- A name (usually reverse-DNS:
com.geekmonks.greeter). - A set of packages it contains.
requires— which other modules it may read.exports— which of its packages are API. Everything else is hidden, evenpublictypes.
| Directive | Job |
|---|---|
module M { } | Name the module |
requires M | Read M’s exported packages |
requires transitive M | Read M and re-export that readability to your callers |
requires static M | Needed at compile time; optional at run time |
exports P | Public types in package P are API |
exports P to M | Qualified export — only M may compile against P |
opens P | Deep reflection at run time (private fields, setAccessible) |
opens P to M | Qualified open — only those modules may reflect |
provides S with I | Register a ServiceLoader implementation |
uses S | Consume a service of type S |
Two rules fall out of that table and are worth memorizing before the lab:
- A named module reads only what it
requires(plusjava.base, which is implicit). - A named module exposes only what it
exports.publicis not enough.
The unnamed module — the classpath — breaks both rules on purpose: it reads every exported package already in the module graph, and it exports (and opens) all of its own packages. That is why classpath apps feel “open” and why adding module-info.java without moving to the module path feels like a no-op.
Lab: two modules, api and impl
A greeter API in one module, an implementation plus main in another. Layout:
greeter.api/
module-info.java
com/geekmonks/greeter/api/Greeter.java
greeter/
module-info.java
com/geekmonks/greeter/FriendlyGreeter.java
com/geekmonks/greeter/Main.java
The API module exports one package. That export is the product:
module com.geekmonks.greeter.api {
exports com.geekmonks.greeter.api;
}
package com.geekmonks.greeter.api;
public interface Greeter {
String greet(String name);
}
The implementation module requires that API. It may export its own package if other code should construct FriendlyGreeter directly; main does not need the export.
module com.geekmonks.greeter {
requires com.geekmonks.greeter.api;
exports com.geekmonks.greeter;
}
package com.geekmonks.greeter;
import com.geekmonks.greeter.api.Greeter;
public final class FriendlyGreeter implements Greeter {
@Override
public String greet(String name) {
return "hello, " + name;
}
}
package com.geekmonks.greeter;
public class Main {
public static void main(String[] args) {
System.out.println(new FriendlyGreeter().greet("modules"));
}
}
Compile each module to its own directory on the module path, then launch by module name. The -m form is module/main-class:
javac -d mods/com.geekmonks.greeter.api \
greeter.api/module-info.java \
greeter.api/com/geekmonks/greeter/api/Greeter.java
javac --module-path mods -d mods/com.geekmonks.greeter \
greeter/module-info.java \
greeter/com/geekmonks/greeter/FriendlyGreeter.java \
greeter/com/geekmonks/greeter/Main.java
java --module-path mods -m com.geekmonks.greeter/com.geekmonks.greeter.Main
hello, modules
--describe-module is the honest dump of what you just declared — including the implicit requires java.base mandated:
java --module-path mods --describe-module com.geekmonks.greeter.api
com.geekmonks.greeter.api
exports com.geekmonks.greeter.api
requires java.base mandated
Now delete the exports line from com.geekmonks.greeter.api and recompile the implementation. public interface Greeter is no longer enough:
greeter/com/geekmonks/greeter/FriendlyGreeter.java:3: error: package com.geekmonks.greeter.api is not visible
import com.geekmonks.greeter.api.Greeter;
^
(package com.geekmonks.greeter.api is declared in module com.geekmonks.greeter.api, which does not export it)
That error is what module-info.java actually buys. Restore the export before continuing.
requires transitive re-exports a dependency
If callers of com.geekmonks.greeter should use Greeter without writing requires com.geekmonks.greeter.api themselves, re-export the API:
module com.geekmonks.greeter {
requires transitive com.geekmonks.greeter.api;
exports com.geekmonks.greeter;
}
Readability is not transitive by default. Without transitive, a module that only requires com.geekmonks.greeter can see FriendlyGreeter and still fail to compile against Greeter. That is usually what you want for an implementation detail; it is the wrong default when the API types leak into your own signatures.
Use requires transitive when your public signatures mention types from another module. Skip it when you only use that module inside your own packages.
opens is for reflection, not for callers
exports is a compile-time (and runtime) contract for source that names your public types. Frameworks that bind fields with reflection need something else: permission to call setAccessible on private members.
module com.geekmonks.app {
requires spring.boot;
requires spring.beans;
requires spring.context;
opens com.geekmonks.app.web to spring.beans, spring.core, spring.context;
opens com.geekmonks.app.entity to hibernate.core;
}
opens (and open module) do not make a package a supported API. Other modules still cannot import those types unless you also exports them. The qualified form (opens … to) is the one you want in production: Spring and Hibernate get a hole; the rest of the world does not.
On the classpath this hole already exists — the unnamed module opens every package. That is why the same entities work in a Boot app with no module-info.java, and why the first modular Spring attempt dies with InaccessibleObjectException until you add opens (or --add-opens at the command line).
Note: --add-opens module/package=ALL-UNNAMED is an escape hatch for other people’s modules, including java.base. Prefer an opens directive in your module-info.java when you control the package.
provides / uses in one glance
JPMS has a first-class ServiceLoader registry. The implementation module advertises; a consumer module declares that it looks the service up:
module com.geekmonks.greeter {
requires com.geekmonks.greeter.api;
provides com.geekmonks.greeter.api.Greeter
with com.geekmonks.greeter.FriendlyGreeter;
}
module com.geekmonks.app {
requires com.geekmonks.greeter.api;
uses com.geekmonks.greeter.api.Greeter;
}
The consumer requires the API, not the impl JAR. Which implementation is on the module path becomes a deployment choice. META-INF/services still works; provides / uses is the modular form with compile-time checking.
Unnamed vs named, classpath vs module path
Classpath (-cp) | Module path (--module-path / -p) | |
|---|---|---|
| What you get | One unnamed module | Named modules (or automatic modules) |
| Reads | Every exported package in the module graph | Only requires (and java.base) |
| Exports | All packages, and they are open | Only exports / opens |
module-info.class | Ignored at run time | Honored |
| Split packages | Merged the old way | Illegal across named modules |
A JAR without module-info that you place on the module path becomes an automatic module. Its name comes from Automatic-Module-Name in the manifest, or from the filename. It exports every package and reads every other module — a bridge for libraries that never modularized. Automatic modules are fine for getting onto the module path; they are not a jlink graph.
Named modules cannot requires the unnamed module. There is no name to write. Types that live only on the classpath are invisible to modular code. That one-way glass is why “just add module-info” in an app that still pulls most of its dependencies from -cp is a mess: the named module cannot see them.
Split packages are illegal
Two named modules must not contain the same package. A consumer that reads both fails at compile or resolve time:
error: module com.geekmonks.client reads package com.geekmonks.greeter.api
from both com.geekmonks.greeter.api and com.geekmonks.other
This is not a style warning. The JVM will not pick a winner. On the classpath the same two JARs would have merged into one package in the unnamed module — which is why the conflict only appears after you modularize.
Do not ship the same package from two libraries if either of them might land on the module path. Fix the package layout, or keep that pair on the classpath.
Classpath reality check
Most Spring Boot applications stay on the classpath, and that is OK. Boot’s fat JAR, the parent classloader, and the Maven/Gradle default of “put the app on -cp” were built for the unnamed module. Java 17 and Java 21 did not change that default.
You still use JPMS every day: java.base is a module, sun.misc.Unsafe and com.sun.* internals are encapsulated, and --add-opens java.base/java.lang=ALL-UNNAMED is the classpath app asking a named JDK module for a reflection hole.
What you do not have to do is modularize your packages. Skip module-info.java until you have a boundary that benefits from enforcement — a library API, a plugin surface, or a runtime you want to shrink with jlink. Copying the file into a Boot repo because Java 9 exists is how this post’s opening paragraph happens.
When modularizing pays: SDK boundaries and jlink
Two payoffs are real. Everything else is optional ceremony.
1. An SDK or library with a hard API. If other teams compile against you, exports is the supported surface and everything else is fair to move. Qualified exports (exports P to M) let a friend module in without publishing the package. This is the JDK’s own trick.
2. A custom runtime. jlink needs an explicit module graph — module-info on every module you include, no automatic modules, no unnamed leftover. From the greeter lab:
jlink --module-path "$JAVA_HOME/jmods:mods" \
--add-modules com.geekmonks.greeter \
--output greeter-runtime \
--launcher greet=com.geekmonks.greeter/com.geekmonks.greeter.Main
greeter-runtime/bin/greet
hello, modules
The image contains java.base, com.geekmonks.greeter.api, com.geekmonks.greeter, and whatever those require — not a full JDK. That is the payoff the classpath cannot offer. If jlink is not a goal, you may not need a module graph yet.
import module is not module-info
Java 25 added module import declarations (import module java.base;). That is a language feature about simple names. It does not name your module, does not export a package, does not shrink a runtime, and does not encapsulate anything.
You do not have to modularize your app to use import module. It is legal in an ordinary class-path source file. Writing module-info.java is the module system; import module is a later convenience that reads a module’s exports. Do not copy a module-info.java because you liked the import syntax, and do not expect import module to stand in for requires / exports.
Cheat sheet
Java 9 / JEP 261: JPMS — module-info.java
Binds only on the module path; ignored inside a classpath JAR
module com.example.app {
requires com.example.api; // read exported packages
requires transitive com.example.api; // re-export readability
requires static com.example.optional; // compile-time, optional at runtime
exports com.example.app.api; // supported API
exports com.example.app.spi to com.example.plugin;
opens com.example.app.web to spring.core;
provides com.example.api.Spi with com.example.app.SpiImpl;
uses com.example.api.Spi;
}
Unnamed module = classpath. Reads everything already in the graph.
Named module = must requires + exports. public is not enough.
Automatic module = JAR on module path, no module-info. Not jlink-able.
Split packages = illegal across named modules.
java --module-path mods -m m/main.Class
java --describe-module M
jlink --add-modules M --output runtime # explicit graph only
Do:
- Put
module-info.javaon the module path, or do not bother writing it. - Export only the packages other modules should compile against.
- Use
requires transitivewhen your signatures mention another module’s types. opens … tothe frameworks that reflect; leave the rest closed.- Modularize for an SDK boundary or a
jlinkruntime.
Don’t:
- Copy
module-info.javainto a Boot app that still runs asjava -jaron-cpand expect encapsulation. - Treat
publicas the API surface of a named module. - Ship the same package from two named modules.
- Confuse
import module(Java 25, simple names) with writing a module descriptor. - Reach for
jlinkwhile any dependency is still an automatic or unnamed module.
Wrap-up
JPMS is a graph: named packages, explicit requires, explicit exports, and opens where reflection must punch through. It shipped as a standard feature in Java 9 (JEP 261) and has not been replaced. The file is cheap to copy and expensive to mean something — it means something only on the module path.
Stay on the classpath if you are writing a typical Spring Boot service. Write module-info.java when you need a hard library boundary or a trimmed jlink image. Keep import module in the other bucket: a Java 25 way to type fewer imports, not a substitute for the module system.