skip to content

modules

module-info.java, requires, exports, opens. strong encapsulation.

chapter
chapter 12.02
reading time
8 min read

a module is a jar with a module-info.java that answers two questions: which modules do i need (requires), and which of my packages may other code use (exports). a package that isn't exported is invisible from outside, public classes included.

that's strong encapsulation: public stops meaning "the whole planet" and starts meaning "public, inside my walls".

the picture

before java 9, public had one setting: everyone. if your library had a public class in com.acme.internal, anyone could import it, and then you could never change it without breaking them. the package name saying "internal" was a polite request. people ignored it (hi, sun.misc.Unsafe).

a module puts a wall around a jar. think of an office building: the floor plan (module-info.java) says which rooms visitors may walk into (exports), and the visitor list says whose offices you're allowed to visit (requires). rooms not on the plan are still there, staff use them all day. you just can't get in from the lobby.

src/shop.orders/module-info.java
module shop.orders {
    requires transitive shop.money;   // i need it, and so will anyone who uses me
    exports shop.orders.api;          // the front door
                                      // shop.orders.internal: not listed, so sealed
}
orders =
exports =
app uses =
shop.app
requires shop.orders;
reads
  • → shop.orders
  • ⇢ shop.money (implied)
  • → java.base (always)
import shop.money.Money;
import shop.orders.api.Orders;
...
Money m = Orders.total(1000);
System.out.println(m);
shop.orders
requires transitive shop.money;exports shop.orders.api;
shop.orders.apiexported
shop.orders.internalhidden
shop.money
exports shop.money;
shop.moneyexported
compiles and runs
$ javac -d mods --module-source-path src $(find src -name "*.java")
$ java -p mods -m shop.app/shop.app.Main
$11.80
flip the switches. every combination was compiled for real with javac 25, and the error text is javac's. notice that without transitive, shop.app can't even name Money, the type that Orders.total() returns.

two words to keep straight:

reads
module A reads module B if A requires B (or gets it implied, below). you can only use types from modules you read.
exports
B makes a package accessible to modules that read it. reading isn't enough: the package also has to be exported.
implied readability
requires transitive B in A means everyone who reads A also reads B. use it when B's types show up in A's public API (Orders.total() returns a Money).

everything a module-info can say

there are only a handful of directives. you'll write the first three 95% of the time.

directivemeans
requires m;i read module m. checked at compile time and at startup
requires transitive m;i read m, and so does anyone who reads me
exports p;public types in package p are usable by anyone who reads me
exports p to m1, m2;a qualified export: only those modules get p (the JDK does this a lot)
opens p;p is open for deep reflection at runtime, private members included
requires static m;needed at compile time, optional at runtime (annotations, optional integrations)
uses S; / provides S with Impl;the ServiceLoader plugin system, declared up front
open module m { }every package is open for reflection. common for apps, rare for libraries

the module path also gets checked when the JVM starts, not just when you compile. a missing requires dependency is a startup error, not a NoClassDefFoundError an hour into production. reliable configuration, the JEP calls it.

exports vs opens

this is the one that confuses everyone, because there are really two kinds of "access". exports is about compiling and calling: can your code use these types? opens is about reflection: can a framework reach in and touch private fields? jackson filling a private field from JSON, hibernate setting an entity's id, spring injecting into a private field: that's all deep reflection, and it needs opens.

module-info =
exports shop.orders.internal;
  • ✓compile against itimport shop.orders.internal.Secret;compiles.
  • ✓reflect on public membersc.getMethod("hello").invoke(null)prints "public method"
  • ✗deep reflection on privatem.setAccessible(true) // what jackson, hibernate, spring doInaccessibleObjectException: ... module shop.orders does not "opens shop.orders.internal" to module shop.app
pick a declaration. green rows are allowed, red rows fail, with the real exception. note that opens alone doesn't let you compile against the package. it's purely a runtime door.

the JDK holds itself to the same rules. java.base exports 58 packages to everyone and keeps the other 138 to itself (57 of those are qualified exports to specific JDK modules). so reaching into String's private bytes doesn't work anymore:

predict the output
java
void main() throws Exception {
    var f = String.class.getDeclaredField("value");
    try {
        f.setAccessible(true);
        IO.println("got in");
    } catch (RuntimeException e) {
        IO.println(e.getClass().getSimpleName());
    }
}
output, from jdk 25
InaccessibleObjectException

this one needs a real JVM (catching a runtime exception), so it doesn't run in the browser. copy it into java Main.java to try it.

the JDK is made of modules too

the JDK was the first big project to be modularized (project jigsaw, java 9). it's 69 modules on temurin 25: the java.* ones are the standard API, the jdk.* ones are this JDK's tools and extras.

Main.java
import java.lang.module.ModuleFinder;
 
void main() {
    var names = ModuleFinder.ofSystem().findAll().stream()
            .map(m -> m.descriptor().name())
            .toList();
    IO.println(names.size() + " modules in this JDK");
    IO.println("java.*: " + names.stream().filter(n -> n.startsWith("java.")).count());
    IO.println("jdk.*:  " + names.stream().filter(n -> n.startsWith("jdk.")).count());
    IO.println("named? " + getClass().getModule().isNamed());
}
output
69 modules in this JDK
java.*: 22
jdk.*:  47
named? false

that last line: code that isn't in a module (like this file) lives in the unnamed module, which reads everything. that's the compatibility story, and it's the whole next chapter: classpath to module path.

  • no code, just 20 requires transitive
  • ├── java.baseeveryone reads this one, no requires needed
  • ├── exports java.sql, javax.sql
  • │ ├── java.logging
  • │ ├── java.xml
  • │ └── java.transaction.xa
  • ├── java.net.httpHttpClient
  • ├── awt, swing, java.beans
  • │ ├── java.xml
  • │ └── java.datatransfer
  • ├── java.naming
  • ├── java.management
  • ├── java.compiler
  • ├── java.scripting
  • └── ...12 more
java.se is a module with no code at all, just requires transitive. click a node to fold it. every edge here comes from java --describe-module on jdk 25.

java.base is special: every module reads it without saying so (--describe-module prints it as requires java.base mandated). it's where String, List and Thread live.

one level deeper:how strong encapsulation got turned on, one release at a time+

java 9 shipped modules but kept a crowbar: --illegal-access=permit was the default, so old code reaching into JDK internals got a warning and kept working. java 16 flipped the default to deny (JEP 396), and java 17 removed the crowbar entirely (JEP 403): JDK internals are sealed, and --add-opens/--add-exports are the only way through. sun.misc.Unsafe is the famous exception. it lives in the jdk.unsupported module, which still exports it so the libraries that depended on it had time to move to VarHandles and the FFM API. its memory methods have been on the way out since 23 (JEP 471) and warn by default since 24 (JEP 498).

keylines
  • a module is a jar plus module-info.java: what it requires, what it exports.
  • not exported = invisible from outside, even if it's public.
  • requires transitive when the other module's types appear in your API.
  • exports is for compiling against you. opens is for reflecting into you.
  • the JDK's own internals are sealed since 17. --add-opens is a crowbar, not a fix.