The module system

Why 8 to 11 is the hard upgrade, what `public` stopped meaning, and the honest answer to whether you should modularise — usually no.

5 min read Modern Java: 8 to 25

The module system arrived in Java 9, split the JDK into modules, and is the reason the 8 → 11 upgrade is the hard one. Most applications still do not use it for their own code — and every application is affected by it, because the JDK does.

That gap is why this lesson exists: you need to understand modules to upgrade, and you probably do not need to adopt them.

The problem it was built for

Before Java 9 there was one classpath, and it had three failures nobody could fix from inside it:

  • Jar hell. Two versions of the same library on the classpath; the first one found wins; which one is first is the order the classpath happens to be in.
  • No encapsulation across jars. public meant public to everything. A library's internal package was as reachable as its API, so people used it, and the library could never change it.
  • No way to know what was missing. A NoClassDefFoundError at run time, hours in, rather than a failure at start.

The JDK had the same problems in the large: rt.jar was a single monolith, every part of it always present, and nothing could be left out of a deployment.

What a module is

A directory of packages with a file that says what it needs and what it offers:

module-info.javajava
module com.example.orders {
    requires com.example.payments;      // what I need
    requires java.sql;                  // JDK modules are modules too
    exports com.example.orders.api;     // what others may use
    // everything else is INACCESSIBLE, even if it is public
}

That last comment is the whole idea: public no longer means reachable. A public class in a package that is not exported cannot be used from another module, and the compiler and the runtime both enforce it. This is strong encapsulation, and it is the thing the classpath could never give you.

Two directives worth knowing beyond the basics:

  • opens allows reflection at run time without allowing compile-time access — which is what frameworks need. Spring and Hibernate reflect into your classes; opens com.example.orders.model; is how you permit that without exporting the package as API.
  • requires transitive means "and anyone who requires me also gets this", for when your API's types come from another module.

The classpath and the module path

They coexist, and the rules for how are most of the practical knowledge:

  • Code on the module path is a module, and its module-info is enforced.
  • Code on the classpath goes into the unnamed module, which reads everything and exports everything — that is the compatibility escape hatch, and it is why an application that ignores modules still runs.
  • A plain jar placed on the module path becomes an automatic module: its name derived from the jar or from Automatic-Module-Name in its manifest, requiring everything and exporting everything.

Automatic modules are the bridge, and they are also the trap: the derived name comes from the filename, so a version change can change the module name and break a requires clause. The fix libraries adopted is Automatic-Module-Name in the manifest, which pins it.

The errors you will actually meet

Upgrading past Java 8 produces a small set of messages, and knowing what each means saves a day:

plaintext
error: package javax.xml.bind is not visible

The Java EE modules were removed in Java 11. Add JAXB as an ordinary dependency; it is on Maven Central.

plaintext
InaccessibleObjectException: Unable to make field private ... accessible:
module java.base does not "opens java.util" to unnamed module

Something is reflecting into the JDK. The escape hatch is --add-opens java.base/java.util=ALL-UNNAMED, and it should be a ticket rather than a permanent flag — a library needing it is a library to upgrade.

plaintext
java.lang.NoClassDefFoundError: javax/annotation/PostConstruct

The same removal, arriving at run time instead of compile time.

Should you modularise your own application?

For most services, no, and the reasoning is worth being explicit about rather than treating as laziness.

What it would buy you: enforced internal boundaries, a smaller runtime image via jlink, and errors at start rather than at run time.

What it costs: every dependency must be a module or an automatic module, reflection needs opens for each framework, the build gets more complex, and a large number of libraries still ship without module descriptors.

And the alternative already exists. A multi-module Maven or Gradle build gives you enforced boundaries at compile time — the build fails if a module imports something it did not declare — which is most of the benefit for none of the cost. The build lesson makes exactly this argument: a module boundary turns an architectural rule into one the compiler enforces.

Where JPMS genuinely earns it: a library with a public API and internals it wants to keep, and jlink where a small runtime image matters — a JDK trimmed to the modules you need is tens of megabytes rather than hundreds.

Progress is saved on this device and to your account when signed in.