The Java Module System

Modules add a boundary above packages: a module declares what it needs and what it exposes, and everything else stays private.

The problem it addresses

  • A public class was visible to everything on the classpath, so internal packages leaked into other people code.
  • The classpath was a flat list with no declared dependencies, so a missing library failed only when the class was first used.
  • The runtime could not be trimmed, so a small application still shipped the whole platform.

The module system, introduced in Java 9, addresses all three by making dependencies and exposure explicit.

A module declaration

// module-info.java, at the root of the source tree
module com.example.notes {

    requires java.sql;                     // what this module needs
    requires transitive com.example.core;  // and re-exports to its consumers

    exports com.example.notes.api;         // visible to everyone
    exports com.example.notes.spi to com.example.plugins;   // visible to one module

    opens com.example.notes.model;         // deep reflection allowed at runtime

    uses com.example.notes.spi.Exporter;                     // a service consumed
    provides com.example.notes.spi.Exporter
            with com.example.notes.JsonExporter;             // a service supplied
}

The directives

DirectiveMeaning
requiresThis module depends on another
requires transitiveAnyone requiring this module also gets that one
requires staticNeeded at compile time, optional at runtime
exportsThe public types of a package are visible outside
exports ... toVisible only to the named modules
opensReflection may reach non public members at runtime
uses and providesService consumer and provider declarations
A package that is not exported is completely invisible outside the module, no matter how public its classes are. This is the central change: public no longer means universally accessible.

Strong encapsulation in practice

// In a module that does not export com.example.notes.internal
package com.example.notes.internal;

public class Cache { }     // public, and still unreachable from other modules
error: package com.example.notes.internal is not visible
  (package com.example.notes.internal is declared in module com.example.notes,
   which does not export it)

exports against opens

exportsopens
Compile time accessYes, public membersNo
Runtime accessYes, public membersYes, including private via reflection
Typical useAn APIFrameworks that map or inject into your objects
open module com.example.notes {   // every package open for reflection
    requires java.sql;
}

Compiling and running

javac -d out --module-source-path src $(find src -name "*.java")
java --module-path out --module com.example.notes/com.example.notes.Main

java --list-modules
java --describe-module com.example.notes
jdeps --module-path out out/com.example.notes

Building a small runtime

jlink --module-path $JAVA_HOME/jmods:out 
      --add-modules com.example.notes 
      --output runtime --strip-debug --compress=2 --no-header-files

jlink produces a runtime image containing only the modules actually required. A simple service can drop from a full JDK to a few tens of megabytes, which matters for container images.

Services

// In the API module
package com.example.notes.spi;

public interface Exporter {
    String format();
    String export(Note note);
}
// In the consuming module
module com.example.notes {
    uses com.example.notes.spi.Exporter;
}

ServiceLoader<Exporter> exporters = ServiceLoader.load(Exporter.class);
for (Exporter exporter : exporters) {
    System.out.println(exporter.format());
}
// In a provider module
module com.example.notes.json {
    requires com.example.notes;
    provides com.example.notes.spi.Exporter with com.example.notes.json.JsonExporter;
}

The consumer never names an implementation. Adding a provider module to the module path is enough for it to be discovered.

Migration

TermMeaning
Named moduleHas a module-info.java
Automatic moduleA plain jar on the module path; its name is derived, and it reads everything
Unnamed moduleEverything on the classpath; it reads all modules

The classpath still works exactly as before, which is why modules can be adopted gradually or not at all. Many applications remain on the classpath and use only the platform modules, and that is a legitimate choice.

# Temporary escape hatches during migration
java --add-exports java.base/sun.nio.ch=ALL-UNNAMED
java --add-opens java.base/java.lang=ALL-UNNAMED

These flags reopen a package so older code keeps working. They are a migration aid, not a design, and each one should be tracked as debt.

Rules to remember

  • Split packages are forbidden: two modules may not contain the same package.
  • Cyclic dependencies between modules are rejected at compile time.
  • A module reads only what it requires; there is no implicit access.
  • java.base is required implicitly by every module.

Common mistakes

  • Expecting a public class in an unexported package to be usable.
  • Forgetting opens and finding that a framework cannot reflect into your model classes.
  • Creating a split package during migration.
  • Using --add-opens permanently instead of fixing the dependency.
  • Assuming modules are mandatory. They are not.

Best practices

  • Export a small API package and keep everything else internal.
  • Use opens ... to rather than opening a package to everyone.
  • Use requires transitive only when your API exposes types from that module.
  • Use jlink when image size matters.
  • Prefer services to hard coded implementation names.
  • Adopt modules where the boundary is genuinely useful, and do not force them elsewhere.

Practice

  1. Write a two module project where one exports an API and the other consumes it.
  2. Make a public class unreachable by not exporting its package, and read the compiler error.
  3. Explain when opens is required rather than exports.
  4. Define a service interface, one provider, and load it with ServiceLoader.
  5. Use jlink to build a runtime image and compare its size with the full JDK.

Conclusion

A module declares what it requires and what it exposes, turning public from "visible to everything" into "visible where I said". Use it to enforce a real API boundary and to ship a smaller runtime, and adopt it where it earns its keep.

Useful resources

Hand picked references for this topic
Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Java notes →
Java

Reflection in Java

Reflection inspects and manipulates classes at runtime. It powers most frameworks and should be rare in application code.

Read more
Java

Dynamic Proxies in Java

A dynamic proxy implements an interface at runtime and routes every call through one handler, which is how cross cutting behaviour is added.

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.