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

ReleaseStatusSpec
Java 9Standard featureJEP 261
Java 11 LTSFirst LTS most teams met it on—
Java 17 / 21 / 25 LTSSame 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, even public types.
DirectiveJob
module M { }Name the module
requires MRead M’s exported packages
requires transitive MRead M and re-export that readability to your callers
requires static MNeeded at compile time; optional at run time
exports PPublic types in package P are API
exports P to MQualified export — only M may compile against P
opens PDeep reflection at run time (private fields, setAccessible)
opens P to MQualified open — only those modules may reflect
provides S with IRegister a ServiceLoader implementation
uses SConsume a service of type S

Two rules fall out of that table and are worth memorizing before the lab:

  1. A named module reads only what it requires (plus java.base, which is implicit).
  2. A named module exposes only what it exports. public is 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 getOne unnamed moduleNamed modules (or automatic modules)
ReadsEvery exported package in the module graphOnly requires (and java.base)
ExportsAll packages, and they are openOnly exports / opens
module-info.classIgnored at run timeHonored
Split packagesMerged the old wayIllegal 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.

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.java on the module path, or do not bother writing it.
  • Export only the packages other modules should compile against.
  • Use requires transitive when your signatures mention another module’s types.
  • opens … to the frameworks that reflect; leave the rest closed.
  • Modularize for an SDK boundary or a jlink runtime.

Don’t:

  • Copy module-info.java into a Boot app that still runs as java -jar on -cp and expect encapsulation.
  • Treat public as 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 jlink while 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.

Next optional step in the series One import module instead of a wall of star imports. Module Import Declarations: One import module Instead of a Wall of Stars