Skip to main content
Java · lesson 38 of 66
Open the Java page →
Java advanced Lesson 38 of 66

Java Module System (JPMS)

Build Java modules step by step: module-info.java, requires and exports, what strong encapsulation blocks, opens for reflection, services and jlink.

By SkillByExample · Updated

A module is a named set of packages with a descriptor, module-info.java, saying which other modules it requires and which of its packages it exports. Everything not exported is hidden, even public classes. The examples build one small project in a working directory: each code block starts with a // file: comment giving its path, and each $ line is run from the project root with JDK 21’s javac, java, jar and jlink.

A first module

A module lives in a directory named after it, with module-info.java at its root.

// file: src/com.greet/module-info.java
module com.greet {
}
// file: src/com.greet/com/greet/Main.java
package com.greet;

public class Main {
    public static void main(String[] args) {
        Module m = Main.class.getModule();
        System.out.println("Hello from module " + m.getName());
        System.out.println("reads java.base? " + m.canRead(Object.class.getModule()));
    }
}
$ javac -d out --module-source-path src -m com.greet
$ java --module-path out -m com.greet/com.greet.Main
Hello from module com.greet
reads java.base? true

--module-source-path src tells javac that each subdirectory of src is a module; -m com.greet compiles that module and anything it needs. At runtime, --module-path replaces the classpath and -m module/class names the entry point. Every module reads java.base implicitly.

requires and exports

Here an API module exports one package and keeps another internal; an app module requires it.

// file: src/com.shop.api/module-info.java
module com.shop.api {
    exports com.shop.api;            // com.shop.api.internal is NOT exported
}
// file: src/com.shop.api/com/shop/api/Prices.java
package com.shop.api;

import com.shop.api.internal.Tax;

public class Prices {
    public static String withTax(int cents) {
        return String.format("%.2f", Tax.apply(cents) / 100.0);
    }
}
// file: src/com.shop.api/com/shop/api/internal/Tax.java
package com.shop.api.internal;

public class Tax {
    public static int apply(int cents) { return cents * 120 / 100; }
}
// file: src/com.shop.app/module-info.java
module com.shop.app {
    requires com.shop.api;
}
// file: src/com.shop.app/com/shop/app/App.java
package com.shop.app;

import com.shop.api.Prices;

public class App {
    public static void main(String[] args) {
        System.out.println("price: " + Prices.withTax(1000));
    }
}
$ javac -d out --module-source-path src -m com.shop.app
$ java --module-path out -m com.shop.app/com.shop.app.App
price: 12.00

Compiling com.shop.app pulled in com.shop.api automatically, because the requires clause names it. Inside its own module, Prices uses the internal Tax class freely.

Strong encapsulation: public is not enough

Another module tries to use the internal package directly.

// file: src/com.sneaky/module-info.java
module com.sneaky {
    requires com.shop.api;
}
// file: src/com.sneaky/com/sneaky/Sneaky.java
package com.sneaky;

import com.shop.api.internal.Tax;

public class Sneaky {
    public static void main(String[] args) {
        System.out.println(Tax.apply(100));
    }
}
$ javac -d out --module-source-path src -m com.sneaky
src/com.sneaky/com/sneaky/Sneaky.java:4: error: package com.shop.api.internal does not exist
import com.shop.api.internal.Tax;
                            ^
src/com.sneaky/com/sneaky/Sneaky.java:8: error: cannot find symbol
        System.out.println(Tax.apply(100));
                           ^
  symbol:   variable Tax
  location: class Sneaky
2 errors

Tax is public, yet from inside com.sneaky its package does not exist at all: it was never exported, so it is not part of what com.sneaky can read. On the classpath this import would have compiled, which is how so much code came to depend on JDK internals such as sun.misc. The API author can now change com.shop.api.internal without breaking anyone.

opens: allowing reflection

Exporting gives compile-time access to public members. Reflection into private members needs opens, which this module does not have yet.

// file: src/com.data/module-info.java
module com.data {
    exports com.data;
}
// file: src/com.data/com/data/User.java
package com.data;

public class User {
    private String name = "ada";
}
// file: src/com.reflect/module-info.java
module com.reflect {
    requires com.data;
}
// file: src/com.reflect/com/reflect/Peek.java
package com.reflect;

import com.data.User;
import java.lang.reflect.Field;

public class Peek {
    public static void main(String[] args) throws Exception {
        Field f = User.class.getDeclaredField("name");
        try {
            f.setAccessible(true);
            System.out.println("name = " + f.get(new User()));
        } catch (RuntimeException e) {
            System.out.println(e.getClass().getSimpleName());
        }
    }
}
$ javac -d out --module-source-path src -m com.reflect
$ java --module-path out -m com.reflect/com.reflect.Peek
InaccessibleObjectException

setAccessible(true) throws InaccessibleObjectException because com.data is exported but not opened. Change the descriptor to open the package — the way you would for Jackson or Hibernate:

// file: src/com.data/module-info.java
module com.data {
    exports com.data;
    opens com.data;                  // or: opens com.data to some.framework;
}
$ javac -d out --module-source-path src -m com.data,com.reflect
$ java --module-path out -m com.reflect/com.reflect.Peek
name = ada

javac -m recompiles only sources newer than their class files in out, so com.data is named explicitly here; compiling just com.reflect would have kept the old descriptor. opens com.data to some.framework restricts deep reflection to one named module, which is safer than opening to everyone. A whole module can also be declared open module.

Services: provides and uses

ServiceLoader lets a module find implementations of an interface without knowing who provides them. Here the API module declares the interface and the app uses it.

// file: src/com.fmt.api/module-info.java
module com.fmt.api {
    exports com.fmt.api;
    uses com.fmt.api.Formatter;
}
// file: src/com.fmt.api/com/fmt/api/Formatter.java
package com.fmt.api;

import java.util.ServiceLoader;

public interface Formatter {
    String format(String s);

    static void main(String[] args) {
        ServiceLoader.load(Formatter.class).stream()
            .map(ServiceLoader.Provider::get)
            .forEach(f -> System.out.println(f.getClass().getSimpleName() + ": " + f.format("Modules")));
    }
}
// file: src/com.fmt.upper/module-info.java
module com.fmt.upper {
    requires com.fmt.api;
    provides com.fmt.api.Formatter with com.fmt.upper.Upper;
}
// file: src/com.fmt.upper/com/fmt/upper/Upper.java
package com.fmt.upper;

import com.fmt.api.Formatter;

public class Upper implements Formatter {
    public String format(String s) { return s.toUpperCase(); }
}
$ javac -d out --module-source-path src -m com.fmt.api,com.fmt.upper
$ java --module-path out -m com.fmt.api/com.fmt.api.Formatter
Upper: MODULES

com.fmt.api never mentions Upper, and Upper’s package is not even exported. The provides clause registers it, and the module system wires it in because com.fmt.upper was on the module path. Drop a different provider module on the path and the output changes with no recompilation.

Inspecting modules

The JDK is itself modular, and the same tools describe your modules.

$ java --describe-module java.sql
java.sql@21.0.12.1
exports java.sql
exports javax.sql
requires java.xml transitive
requires java.base mandated
requires java.transaction.xa transitive
requires java.logging transitive
uses java.sql.Driver
$ java --list-modules | wc -l
69

java.sql uses requires transitive, so any module that requires java.sql can also read java.logging, java.transaction.xa and java.xml without naming them. The full JDK is made of dozens of such modules; jar --describe-module --file x.jar prints the same summary for a modular JAR.

jlink assembles a Java runtime containing just the modules an application requires.

$ jlink --module-path out --add-modules com.greet --output greet-runtime
$ greet-runtime/bin/java --list-modules
com.greet
java.base@21.0.12.1
$ greet-runtime/bin/java -m com.greet/com.greet.Main
Hello from module com.greet
reads java.base? true

The image holds only com.greet and java.base, the one module it needs. The full JDK counted above has many more; images like this are a common way to shrink container images.

Practice

  1. Make com.shop.app print the result of Tax.apply by changing only com.shop.api’s descriptor, using a qualified export so no other module gains access.
Solution
// file: src/com.shop.api/module-info.java
module com.shop.api {
    exports com.shop.api;
    exports com.shop.api.internal to com.shop.app;
}
// file: src/com.shop.app/com/shop/app/App.java
package com.shop.app;

import com.shop.api.Prices;
import com.shop.api.internal.Tax;

public class App {
    public static void main(String[] args) {
        System.out.println("price: " + Prices.withTax(1000));
        System.out.println("tax applied: " + Tax.apply(1000));
    }
}
$ javac -d out --module-source-path src -m com.shop.app
$ java --module-path out -m com.shop.app/com.shop.app.App
price: 12.00
tax applied: 1200
$ javac -d out --module-source-path src -m com.sneaky
src/com.sneaky/com/sneaky/Sneaky.java:4: error: package com.shop.api.internal does not exist
import com.shop.api.internal.Tax;
                            ^
src/com.sneaky/com/sneaky/Sneaky.java:8: error: cannot find symbol
        System.out.println(Tax.apply(100));
                           ^
  symbol:   variable Tax
  location: class Sneaky
2 errors

exports ... to com.shop.app is visible to that one module only; com.sneaky still fails to compile.

  1. Add a second provider module, com.fmt.reverse, whose Reverse formatter reverses the string. Run the Formatter entry point again without changing com.fmt.api.
Solution
// file: src/com.fmt.reverse/module-info.java
module com.fmt.reverse {
    requires com.fmt.api;
    provides com.fmt.api.Formatter with com.fmt.reverse.Reverse;
}
// file: src/com.fmt.reverse/com/fmt/reverse/Reverse.java
package com.fmt.reverse;

import com.fmt.api.Formatter;

public class Reverse implements Formatter {
    public String format(String s) { return new StringBuilder(s).reverse().toString(); }
}
$ javac -d out --module-source-path src -m com.fmt.reverse
$ java --module-path out -m com.fmt.api/com.fmt.api.Formatter
Reverse: seludoM
Upper: MODULES

Only the new module was compiled; ServiceLoader discovers both providers at runtime. Providers are found in module order, so do not rely on a particular order between them.

  1. Remove the requires com.shop.api line from com.shop.app (leaving an empty module com.shop.app {}) and compile. What error do you get?
Solution
// file: src/com.shop.app/module-info.java
module com.shop.app {
}
$ javac -d clean-out --module-source-path src -m com.shop.app
src/com.shop.app/com/shop/app/App.java:4: error: package com.shop.api does not exist
import com.shop.api.Prices;
                   ^
src/com.shop.app/com/shop/app/App.java:5: error: package com.shop.api.internal does not exist
import com.shop.api.internal.Tax;
                            ^
src/com.shop.app/com/shop/app/App.java:9: error: cannot find symbol
        System.out.println("price: " + Prices.withTax(1000));
                                       ^
  symbol:   variable Prices
  location: class App
src/com.shop.app/com/shop/app/App.java:10: error: cannot find symbol
        System.out.println("tax applied: " + Tax.apply(1000));
                                             ^
  symbol:   variable Tax
  location: class App
4 errors

Compiling into a fresh clean-out directory forces every source to be compiled (-m skips up-to-date classes). Without requires, the module cannot read com.shop.api, so even its exported package is not visible. Dependencies must be declared — the “reliable configuration” half of JPMS.

Frequently Asked Questions

Do I need to use modules for every Java project?
No. Modules are optional, and code on the classpath runs in the unnamed module exactly as before. They pay off for libraries and large applications that want strong encapsulation, explicit dependencies and small custom runtimes via jlink. Many applications, including most Spring Boot services, stay on the classpath.
What is the difference between exports and opens?
exports makes a package's public types usable by other modules at compile time and runtime. opens grants deep reflective access at runtime, including to private members, which frameworks such as Jackson and Hibernate need, but does not make the package usable at compile time. A package can be both exported and opened.
What is a split package and why is it forbidden?
A split package is the same package name appearing in two modules. The module system forbids it because it could not tell which module should supply a type from that package. The classpath allowed split packages, which caused hard-to-find bugs when two JARs shadowed each other's classes.
How do I use a library that is not yet modularised?
Put the plain JAR on the module path and it becomes an automatic module, named from its Automatic-Module-Name manifest entry or, failing that, its file name. Your module can then require it by that name. JARs left on the classpath go into the unnamed module, which named modules cannot require.
What does jlink do?
jlink builds a custom Java runtime image that contains only the modules your application needs, plus their dependencies. The result is a self-contained directory with its own bin/java, often far smaller than a full JDK, which suits containers and desktop distribution.