Skip to main content

Command Palette

Search for a command to run...

The Java Platform Module System — Fundamentals

Updated
40 min readView as Markdown
The Java Platform Module System — Fundamentals
D

I am a programmer who specializes in Java and Scala programming. My specialization is in the creation of telecommunication services for IMS networks using Java, Jain Slee specification, and SIP technology. My interest lies in Swift programming and Cloud technology, specifically designing cloud-based services using micro services and the JVM platform. Astronomy, martial arts, and strategic games are things I enjoy in my free time.

This is the first article in a four-part series on the Java Platform Module System — a feature that has been part of the platform since Java 9, yet remains largely unexplored by most of the developer community.

If you have been working with Java for years and never needed to touch module-info.java, you are in good company. Most enterprise projects run on the classpath without issues — Spring Boot 4, Spring Framework 7, and Jakarta EE 11 all support modules but none of them require it. Industry reports from New Relic and InfoQ do not even track module adoption as a separate metric. Yet modules solve real problems, and understanding them helps you decide when they earn their place in a project.

In this article I take a detailed look at the fundamentals of JPMS: where it came from, how module declarations work, what strong encapsulation actually means in practice, and how the core directives — exports, requires, opens, requires static, requires transitive, uses, provides...with, and qualified exports — interact in a real project.

I am planning four articles on this topic:

  1. The Java Module System — Fundamentals (this article) — module declarations, directives, encapsulation, and how the pieces fit together

  2. The Java Module System — Deep Dive — unnamed and automatic modules, JDK internal encapsulation, and command-line escape hatches

  3. Java Modules and Docker — building minimal container images with jlink and custom runtimes

  4. Building a Service with Java Modules — designing a plugin architecture using ServiceLoader and modular boundaries

Let us start with where modules came from and why they exist.


Why Modules Exist — The Real Story

The history of JPMS (Java Platform Module System) is well documented through blog posts, conference talks, and interviews from the people who built it. What follows is a summary based on those primary sources.

Oracle's Problem

By 2008, the JDK had grown into a monolithic distribution of roughly 150 MB. Every Java installation shipped everything — Swing, CORBA, XML parsers, JNDI — whether the application needed them or not. Mark Reinhold described the situation in his 2012 post "The massive, monolithic JDK": the platform had become too large to scale down to small devices and too tangled internally to evolve safely.

Widely used internal APIs like sun.misc.Unsafe and com.sun.* packages were never intended to be public, but half the ecosystem depended on them. Oracle could not remove or refactor these internals without breaking frameworks like Guava, Netty, and Hibernate.

The classpath model compounded the problem. All JARs were dumped into a flat search path where the first class found wins. Duplicate packages across JARs? Silently merged. Missing dependencies? Discovered at runtime, not compile time. The system worked well enough for most applications, but it made the JDK itself nearly impossible to evolve.

Project Jigsaw Timeline

Project Jigsaw started in 2008 with a small team and limited funding. Its journey to production is documented across Reinhold's blog and the OpenJDK Project Jigsaw page:

  • 2008: Mark Reinhold proposes modularizing the JDK. The project runs on a "shoestring budget" with a handful of part-time engineers.

  • 2010: Oracle acquires Sun Microsystems. The project stalls during the merger transition.

  • 2012: Modules were planned for Java 7 — postponed.

  • 2013: Planned again for Java 8 — Reinhold officially defers to Java 9 in "Late for the Train".

  • 2014: The project finally gets a full-time team and serious investment.

  • 2017: JPMS ships with Java 9 — nine years after inception. Reinhold marks the milestone in "Project Jigsaw: Complete!".

Three Official Goals

According to the Project Jigsaw overview, the module system was designed to solve three problems:

  1. Strong encapsulation — hide internal implementation details so that only explicitly exported packages are accessible to other modules

  2. Reliable configuration — detect missing dependencies, duplicate modules, and package conflicts at startup rather than at runtime

  3. Scalable platform — allow the JDK to be decomposed into modules so that applications can ship with only the parts they need

The Narrative Shift

When Jigsaw started in 2008, the pitch was about scaling Java down to small devices and embedded systems. By the time it shipped in 2017, that market had largely been captured by Android, C, and Python. Reinhold himself acknowledged the shift in the "Project Jigsaw: The module system" post:

"The last of these goals was originally intended to reduce download times and scale Java SE down to small devices, but it is today just as relevant to dense deployments in the cloud." — Mark Reinhold, 2017

The strongest practical argument for modules in 2026 is not embedded devices but container optimization — a use case we explore in detail in the third article of this series.

Adoption in 2026

Community reaction at launch was cautious. In an InfoQ article from 2018, Martijn Verburg of the London Java Community summarized the sentiment:

"The module system is clearly a big win for the JDK itself... However, it may not get broad mindshare or usage from the day to day app developer."

Eight years later, the data confirms his prediction. The 2024 New Relic State of the Java Ecosystem report, the InfoQ Java Trends Report 2025, and the JRebel Developer Productivity Report do not mention module adoption at all. They track Java versions, JDK vendors, garbage collectors, and frameworks — but modules remain invisible in the metrics. Spring and Jakarta support modules but default to classpath. Most enterprise projects have never written a module-info.java.

This does not mean modules are useless — it means their value is concentrated in specific scenarios rather than being a universal upgrade. The JDK's own modularization was a clear success. For application developers, the question is whether their project falls into one of those specific scenarios.

With that context in mind, let us look at how the module system actually works through a concrete project.


The Library Project — A Running Example

To explore the module system through working code rather than abstract descriptions, I prepared a small application for this article: a Library management system built entirely with JPMS modules. The project is deliberately simple — the point is not the domain logic but the way seven modules interact, depend on each other, and enforce boundaries between their internals and public APIs.

The system manages a collection of books. You can add books to the library, search by title or author, format book information in different ways (plain text, JSON), serialize entities via reflection, and optionally track usage analytics. Each of these responsibilities lives in a separate module with its own module-info.java, its own exported API, and its own encapsulated internals.

Module Role Key JPMS Feature
library-api Shared types: Book, BookRepository, BookFormatter exports
library-core Business logic (BookService) and internal utilities (IsbnValidator) requires transitive, qualified exports...to, non-exported packages
library-format-provider Pluggable output formatters discovered at runtime provides...with (no exports — invisible to consumers)
library-data Entity classes that need reflection access for serialization opens
library-analytics Optional usage tracking — may be absent at runtime regular module — consumed via requires static by library-app
library-app Application entry point that wires everything together requires, requires static, uses
library-reflect-demo Standalone demo of cross-module reflective access requires + setAccessible from outside the module

Throughout this article we will use this project as a running example. Each section below demonstrates a specific JPMS directive through one of these modules.

Getting the Code

The full source code is available at blog-9mac-dev-code. To clone and build:

git clone https://github.com/dawid-swist/blog-9mac-dev-code.git
cd blog-9mac-dev-code/blog-post-examples/java/2026-07-13-java9-module-system

# Build all modules and run the test suite
gradle build

# Run the application — exercises all modules together
gradle :blog-post-examples:java:2026-07-13-java9-module-system:library-app:run

The project requires Java 25+ and a local Gradle 9 installation. No additional setup is needed.


Module Declarations — How It Works

A module in Java can be thought of as a higher-level grouping above packages — a named, self-describing component that declares what it needs from the outside world and what it exposes to it. If a package organizes classes, a module organizes packages. Every module has a unique name, a list of dependencies on other modules, and explicit control over which of its packages are visible externally.

All of this is defined in a single file: module-info.java, placed in the source root of a project. The compiler processes it into module-info.class, which is included in the root of the resulting JAR. A JAR containing module-info.class is called a modular JAR.

Here is a real example from the JDK — the declaration of the java.sql module:

module java.sql {
    requires transitive java.logging;
    requires transitive java.transaction.xa;
    requires transitive java.xml;

    exports java.sql;
    exports javax.sql;

    uses java.sql.Driver;
}

This tells the module system three things: java.sql depends on java.logging, java.transaction.xa, and java.xml (and passes those dependencies along to its consumers), it exposes two packages, and it uses ServiceLoader to discover JDBC driver implementations.

The general form of a module declaration:

module $MODULE_NAME {
    requires ...;          // dependencies
    exports ...;           // packages accessible to other modules
    opens ...;             // packages accessible via reflection
    uses ...;              // services consumed via ServiceLoader
    provides ... with ...; // service implementations supplied
}

In module-info.java we can use the following types of declarations:

Directive Purpose
requires Declares a compile-time and runtime dependency on another module
exports Makes a package accessible to other modules (compile and runtime)
opens Allows deep reflective access to a package (runtime only)
uses Declares that this module discovers service implementations via ServiceLoader
provides...with Registers a service implementation for discovery by other modules

The following sections demonstrate each directive through working code from our Library project.

Module Names and the exports Directive

Module names follow the reverse-domain convention — the same convention used for Java packages. This ensures global uniqueness across the ecosystem. By convention, a module name matches its root package — for example, a module named dev.nmac.library.api exports the dev.nmac.library.api package.

The simplest module in our Library system is library-api — it has one directive:

// library-api/src/main/java/module-info.java
module dev.nmac.library.api {
    exports dev.nmac.library.api;
}

The Book record is the primary type exported by this module — an immutable representation of a book with built-in validation:

// library-api — Book.java
public record Book(String title, String author, String isbn, int year) {

    public Book {
        if (title == null || title.isBlank()) {
            throw new IllegalArgumentException("Title must not be blank");
        }
        if (isbn == null || isbn.isBlank()) {
            throw new IllegalArgumentException("ISBN must not be blank");
        }
    }
}

Any module that requires dev.nmac.library.api can create and use Book instances:

var book = new Book("Effective Java", "Joshua Bloch", "978-0134685991", 2018);
System.out.println(book.title() + " by " + book.author());
Effective Java by Joshua Bloch

Key Insight: The exports directive replaces the classpath's implicit rule that every public class is accessible to all code. With modules, a package must be explicitly exported — everything else is encapsulated, regardless of Java access modifiers.

Source code: library-api/src/main/java/ — the complete module with Book, BookRepository, and BookFormatter interfaces. Run the tests with gradle :library-api:test.

The API module exports its types, but what about the module that uses them?

Declaring Dependencies with requires

The requires directive declares an explicit dependency on another module. Without it, the compiler rejects any reference to types from that module — no more hidden dependencies discovered at runtime.

Is requires Redundant with Build Tools?

A common first reaction: "I already declare dependencies in build.gradle or pom.xml — why declare them again in module-info.java?"

The two declarations serve different purposes:

  • Maven/Gradle — resolves group:artifact:version coordinates, downloads JARs from repositories, manages transitive dependency trees, handles version conflicts

  • module-info.java — defines module-name relationships that the JVM enforces at compile time and runtime: which packages are visible, which modules can access what

Other ecosystems (npm, Cargo, Go) combine both roles into a single file because their module systems were designed together with their package managers. Java could not do this — Maven existed since 2004 and Gradle since 2012, fifteen years before modules arrived. Oracle does not control these external tools, so module-info.java operates at the language level while build tools operate at the infrastructure level.

The Core Module Declaration

// library-core/src/main/java/module-info.java
module dev.nmac.library.core {
    requires transitive dev.nmac.library.api;

    exports dev.nmac.library.core;
    exports dev.nmac.library.core.search to dev.nmac.library.app;
    // dev.nmac.library.core.internal is NOT exported
}

Three features in one declaration:

  1. requires transitive — depends on the API module and re-exports it, so any module that requires library.core automatically gets access to library.api types

  2. Unqualified exports — makes dev.nmac.library.core accessible to all modules

  3. Qualified exports...to — restricts dev.nmac.library.core.search to only dev.nmac.library.app

The BookService class lives in the exported package and delegates persistence to a BookRepository:

// library-core — BookService.java
public class BookService {

    private final BookRepository repository;

    public BookService(BookRepository repository) {
        this.repository = repository;
    }

    public boolean addBook(Book book) {
        if (repository.findByIsbn(book.isbn()).isPresent()) {
            return false;
        }
        repository.save(book);
        return true;
    }

    public Optional<Book> findByIsbn(String isbn) {
        return repository.findByIsbn(isbn);
    }
}
var service = new BookService(new InMemoryBookRepository());
service.addBook(new Book("Effective Java", "Joshua Bloch", "978-0134685991", 2018));
service.addBook(new Book("Clean Code", "Robert Martin", "978-0132350884", 2008));

System.out.println("Books in library: " + service.totalBooks());
System.out.println("Found: " + service.findByIsbn("978-0134685991").orElseThrow().title());
Books in library: 2
Found: Effective Java

Key Insight: The requires directive turns the classpath's implicit dependency model into explicit, compiler-verified declarations. If a module references types from another module without a matching requires, compilation fails immediately.

Source code: library-core/src/main/java/BookService, InMemoryBookRepository, and the internal IsbnValidator. Run the tests with gradle :library-core:test.

So far, every class we have seen lives in an exported package. But what happens to a public class in a package the module chooses not to export?

Strong Encapsulation — Public Does Not Mean Accessible

Before Java 9, any public class on the classpath was accessible to all code. The module system changes this by adding two additional conditions. For code in module A to access a type in module B, all three must hold:

  1. The type is public

  2. Its package is exports-ed by module B

  3. Module A requires module B (directly or transitively)

If any condition fails, the compiler reports an error and the JVM throws IllegalAccessError.

The IsbnValidator lives in dev.nmac.library.core.internal — a package that is intentionally not exported:

// dev.nmac.library.core.internal — NOT exported
public class IsbnValidator {

    public static boolean isValid(String isbn) {
        if (isbn == null || isbn.isBlank()) {
            return false;
        }
        String digits = isbn.replace("-", "");
        return digits.length() == 13 && digits.chars().allMatch(Character::isDigit);
    }

    public static void requireValid(String isbn) {
        if (!isValid(isbn)) {
            throw new IllegalArgumentException("Invalid ISBN format: " + isbn);
        }
    }
}

InMemoryBookRepository uses this validator internally — both classes live in the same module, so access is unrestricted within the module boundary:

// library-core — InMemoryBookRepository.java (excerpt)
public class InMemoryBookRepository implements BookRepository {

    @Override
    public void save(Book book) {
        IsbnValidator.requireValid(book.isbn());
        books.put(book.isbn(), book);
    }
}

Attempting to use IsbnValidator from another module — say, library-app — fails at compile time:

// library-app — this will NOT compile
import dev.nmac.library.core.internal.IsbnValidator;
error: package dev.nmac.library.core.internal is not visible
    (package dev.nmac.library.core.internal is declared in module
     dev.nmac.library.core, which does not export it)

The class is public, the module is on the module path, yet the compiler refuses access — because the package is not exported. This is strong encapsulation in action.

Key Insight: Strong encapsulation means that public no longer equals "accessible everywhere." Modules draw a clear boundary between internal implementation and public API — a distinction that Java access modifiers alone could never enforce across JAR boundaries.

Source code: The internal package lives at library-core/src/main/java/dev/nmac/library/core/internal/. Try importing IsbnValidator from a different module in your clone — the compiler will reject it.

With exports, requires, and encapsulation covered, let us see how the application module ties all the pieces together.

Putting It All Together — The Application Module

The application module sits at the top of the dependency graph. Its module-info.java combines four different directives:

// library-app/src/main/java/module-info.java
module dev.nmac.library.app {
    requires dev.nmac.library.core;
    requires dev.nmac.library.data;
    requires static dev.nmac.library.analytics;
    uses dev.nmac.library.api.BookFormatter;
}

What each line means:

  • requires dev.nmac.library.core — and because library.core declares requires transitive dev.nmac.library.api, the app automatically gets access to all API types. No explicit requires dev.nmac.library.api is needed.

  • requires dev.nmac.library.data — a data module with opens for reflective serialization (covered in Open Modules and Reflection).

  • requires static dev.nmac.library.analytics — the analytics module is optional at runtime. The code compiles against it but handles its absence gracefully.

  • uses dev.nmac.library.api.BookFormatter — declares intent to discover BookFormatter implementations via ServiceLoader at runtime (covered in Service Discovery).

The LibraryApp.main() method exercises every module in the system — core services, qualified exports, ServiceLoader discovery, reflection-based serialization, and optional dependencies:

// library-app — LibraryApp.java (excerpt)
var repository = new InMemoryBookRepository();
var service = new BookService(repository);

service.addBook(new Book("Effective Java", "Joshua Bloch", "978-0134685991", 2018));
service.addBook(new Book("Clean Code", "Robert Martin", "978-0132350884", 2008));
service.addBook(new Book("Java Puzzlers", "Joshua Bloch", "978-0321336781", 2005));

System.out.println("Books in library: " + service.totalBooks());

// Qualified export — BookSearchEngine is only accessible to this module
var searchEngine = new BookSearchEngine(repository);
System.out.println("Search for 'Bloch': " + searchEngine.search("Bloch").size() + " books found");

// ServiceLoader — discover formatter implementations at runtime
ServiceLoader<BookFormatter> formatters = ServiceLoader.load(BookFormatter.class);
for (BookFormatter f : formatters) {
    System.out.println("  [" + f.name() + "] " + f.format(sampleBook));
}

// opens directive — reflection-based serialization
var entity = BookEntity.fromBook(sampleBook);
System.out.println("\nSerialized via reflection: " + serialized);

The full output is shown in Building and Running.

A single module-info.java serves as a blueprint of all a module's external relationships — dependencies, exported API, optional components, and service contracts — information that was previously scattered across build files, documentation, and implicit assumptions.

Run it yourself: gradle :library-app:run — this executes LibraryApp.main() on the module path with all six modules resolved. The full source is at library-app/src/main/java/dev/nmac/library/app/LibraryApp.java.


Service Discovery — uses and provides...with

Two directives work in tandem to decouple service consumers from their implementations: uses declares that a module will look up implementations at runtime, and provides...with registers an implementation for discovery. The key property: no requires edge is needed between the consumer and the provider.

The Consumer — uses

module dev.nmac.library.app {
    // ...
    uses dev.nmac.library.api.BookFormatter;
}

uses tells the module system: "this module will call ServiceLoader.load(BookFormatter.class) at runtime." The declaration is required — without it, ServiceLoader returns an empty iterator even if providers are present on the module path.

The Provider — provides...with

// library-format-provider/src/main/java/module-info.java
module dev.nmac.library.format.provider {
    requires dev.nmac.library.api;

    provides dev.nmac.library.api.BookFormatter
        with dev.nmac.library.format.provider.PlainTextBookFormatter,
             dev.nmac.library.format.provider.JsonBookFormatter;
}

library-format-provider does not export any package. Its classes are invisible to other modules as a compile-time dependency — they only surface through ServiceLoader discovery. A single provides...with clause can register multiple implementations.

How They Connect

library-app never declares requires dev.nmac.library.format.provider. Instead, both modules are placed on the module path, and the module system links them automatically at startup:

dev.nmac.library.app              uses     dev.nmac.library.api.BookFormatter
dev.nmac.library.format.provider  provides dev.nmac.library.api.BookFormatter

The application code discovers all registered formatters at runtime:

ServiceLoader<BookFormatter> formatters = ServiceLoader.load(BookFormatter.class);
for (BookFormatter f : formatters) {
    System.out.println("[" + f.name() + "] " + f.format(book));
}

This is the plugin architecture pattern: add or swap formatters by changing what is on the module path, without recompiling anything. The provider module is an implementation detail — consumers only depend on the BookFormatter interface from library-api.

The full ServiceLoader API, multi-provider scenarios, ordering, and lazy loading are covered in Part 4 — Building a Service with Java Modules (coming soon).


Open Modules and Reflection

The Problem: Reflection and Strong Encapsulation

A typical entity class has private fields and a no-argument constructor — the kind of class that JPA, Jackson, and virtually every serialization framework expects. Before Java 9, any code on the classpath could call Field.setAccessible(true) to bypass the private modifier and read or write fields directly. This was the foundation of the entire reflection-based ecosystem.

The module system changes this. Even if a package is exported, exports only grants compile-time and runtime access to public types and their public members. It does not permit reflective access to private fields, private constructors, or package-private members. When a framework running in one module calls setAccessible(true) on a private field belonging to a class in another module, the JVM throws:

java.lang.reflect.InaccessibleObjectException:
  Unable to make field private java.lang.String dev.nmac.library.data.BookEntity.title
  accessible: module dev.nmac.library.data does not "opens dev.nmac.library.data"
  to module dev.nmac.library.reflect.demo

The error message is precise: it names the field, the module that owns it, and what directive is missing. This is strong encapsulation doing its job — but it also means that without an explicit opt-in, every reflection-based framework in the Java ecosystem would break when moved to the module path.

The exports directive is a compile-time mechanism for public API access — it was never intended to cover the deep reflective access that frameworks depend on. That requires a different directive entirely.

The opens Directive

The opens directive grants runtime reflective access to all types and members within a package — including private fields, private constructors, and package-private classes. Unlike exports, it has no effect at compile time: code in other modules cannot import types from an opened package directly. The access is exclusively through the Reflection API.

module dev.nmac.library.data {
    requires dev.nmac.library.api;

    exports dev.nmac.library.data;    // compile-time access to public types
    opens dev.nmac.library.data;      // runtime reflective access to all members
}

With both directives on the same package, other modules can import BookEntity directly (via exports) and frameworks can reflect into its private fields at runtime (via opens).

The distinction between the two directives is fundamental:

Aspect exports opens
Compile-time access Yes (public types only) No
Runtime access Yes (public types only) Yes (all members via reflection)
Private field access No Yes (via setAccessible)
Private constructor access No Yes (via getDeclaredConstructor)
API stability signal Yes — public contract No — implementation detail

A package can have exports without opens (compile-time API, no reflection), opens without exports (reflection-only, no direct import), or both. Each combination serves a different architectural intent.

Qualified Opens

Just as exports supports a qualified form (exports...to), the opens directive can be restricted to specific modules:

opens dev.nmac.library.data to com.google.gson, com.fasterxml.jackson.databind;

This grants reflective access only to the named modules. In practice, qualified opens is less common than qualified exports — framework modules change names across versions, and the whole point of opening a package is typically to support any framework that needs reflection. Qualified opens is most useful when you know the exact target module and want to limit the exposure surface.

Key Insight: The opens directive complements exports by covering a different axis of access. Where exports controls what other modules can compile against, opens controls what they can reflect into at runtime. Neither replaces the other.

The open module Declaration

When a module contains multiple packages that all need reflective access, listing individual opens directives for each package becomes tedious. The open module declaration is a shorthand that opens all packages in the module for reflection:

open module dev.nmac.library.data {
    requires dev.nmac.library.api;

    exports dev.nmac.library.data;
}

The open keyword before module means every package is available for deep reflection at runtime, as if each had its own opens directive. The exports directive still controls compile-time visibility independently. One restriction: a module declared as open cannot also contain individual opens directives — it is all-or-nothing. If you need per-package control, use explicit opens directives in a regular module.

In Practice: The library-data and library-reflect-demo Modules

Our Library project uses a per-package opens directive in library-data rather than open module — the module has only one package, so the two approaches are equivalent, but the per-package form keeps the opt-in explicit and serves as a better illustration of the directive. A separate library-reflect-demo module demonstrates reflective access from outside the module.

The Module Declaration

// library-data/src/main/java/module-info.java
module dev.nmac.library.data {
    requires dev.nmac.library.api;

    exports dev.nmac.library.data;  // compile-time access to public types
    opens dev.nmac.library.data;    // runtime reflective access to all members
}

Both directives target the same package. exports allows other modules to import BookEntity at compile time. opens allows other modules to call field.setAccessible(true) on BookEntity's private fields at runtime.

The library-reflect-demo Module

// library-reflect-demo/src/main/java/module-info.java
module dev.nmac.library.reflect.demo {
    requires dev.nmac.library.data;
}
// ReflectionDemo.java — in module dev.nmac.library.reflect.demo
public class ReflectionDemo {

    public static Map<String, Object> readAllFields(BookEntity entity) {
        Map<String, Object> result = new LinkedHashMap<>();
        for (Field field : entity.getClass().getDeclaredFields()) {
            field.setAccessible(true); // works because library-data opens its package
            try {
                result.put(field.getName(), field.get(entity));
            } catch (IllegalAccessException e) {
                result.put(field.getName(), "<inaccessible>");
            }
        }
        return result;
    }
}

field.setAccessible(true) succeeds here because library-data has opens dev.nmac.library.data — granting reflective access to any module, including dev.nmac.library.reflect.demo.

Without opens: The InaccessibleObjectException

If library-data had no opens directive:

// hypothetical — no opens
module dev.nmac.library.data {
    requires dev.nmac.library.api;
    exports dev.nmac.library.data;
}

The setAccessible(true) call in ReflectionDemo would throw at runtime:

java.lang.reflect.InaccessibleObjectException:
  Unable to make field private java.lang.String dev.nmac.library.data.BookEntity.title
  accessible: module dev.nmac.library.data does not "opens dev.nmac.library.data"
  to module dev.nmac.library.reflect.demo

The JVM names the field, the module that owns it, and exactly which directive is missing. This is the same error Hibernate, Jackson, and Spring Data produce when entity modules are not properly opened for reflection.

Source code: library-data/ and library-reflect-demo/ — the module declarations and ReflectionDemo with full assertions.

library-data also contains a ReflectiveSerializer — an internal class that serializes BookEntity fields through reflection from within the same module. It needs no opens directive because a module always has full reflective access to its own internals. The library-reflect-demo module demonstrates the real constraint: accessing private fields of a class in a different module requires that module to explicitly opens the package. The alternative — the --add-opens command-line flag — is a runtime workaround rather than a design-time solution, examined in the companion article.


Optional Dependencies

requires static creates a dependency that is mandatory at compile time but optional at runtime: the module must be present when compiling, but the JVM starts whether or not it is on the module path.

requires static Semantics

module dev.nmac.library.app {
    requires dev.nmac.library.core;
    requires dev.nmac.library.data;

    requires static dev.nmac.library.analytics;  // optional at runtime

    uses dev.nmac.library.api.BookFormatter;
}
  • Compile time: the dependency must be present. The compiler resolves types normally — full type checking, autocompletion, import resolution.

  • Runtime: the dependency may be absent. The module system does not require it during startup. The application starts whether or not library-analytics is on the module path.

An absent requires static module enters the graph only if another module requires it non-statically, or if it is explicitly added via --add-modules.

The Risk: NoClassDefFoundError

If runtime code references a type from an absent optional module, the JVM throws NoClassDefFoundError:

Exception in thread "main" java.lang.NoClassDefFoundError:
  dev/nmac/library/analytics/LibraryAnalytics

The safe pattern wraps every call in a try/catch using fully qualified class names — if the class is imported at the top of the file, the error may surface during class loading rather than at the specific call site:

private static void trackAnalytics(Book book) {
    try {
        dev.nmac.library.analytics.LibraryAnalytics.trackBookAdded(book);
        System.out.println(
            "Analytics: " + dev.nmac.library.analytics.LibraryAnalytics.report()
        );
    } catch (NoClassDefFoundError e) {
        System.out.println("Analytics module not available");
    }
}

In Practice: Optional Analytics

library-analytics is a regular module — it does not know it is optional. The optional relationship is entirely defined in the consuming module:

// library-analytics/src/main/java/module-info.java
module dev.nmac.library.analytics {
    requires dev.nmac.library.api;

    exports dev.nmac.library.analytics;
}

With analytics on the module path:

Books in library: 3
Analytics: Library Analytics: 1 books added, 0 searches performed, 1 unique authors

Without analytics (default in this project — declared compileOnly in Gradle):

Books in library: 3
Analytics module not available

Source code: library-app/src/main/java/dev/nmac/library/app/LibraryApp.java and library-analytics/src/main/java/dev/nmac/library/analytics/LibraryAnalytics.java.


Implied Readability

When a module exposes types from another module in its public API, every consumer of that API must also be able to read the underlying module. Declaring these transitive dependencies manually across every consumer is tedious and fragile. The requires transitive directive solves this by propagating readability automatically through the module graph.

The Problem: Hidden Dependencies

Consider three modules arranged in a chain: library-api defines the Book record, library-core provides BookService whose methods accept and return Book instances, and library-app uses BookService to manage a collection. The natural dependency graph looks straightforward: the app requires core, and core requires api.

The trouble appears when library-app tries to use a Book object returned by BookService. Even though library-app never directly imports from library-api, the compiler needs to resolve the Book type because it appears in the method signatures of BookService. Without explicit access to the api module, compilation fails.

Suppose library-core declares a plain requires on the api module:

// library-core/module-info.java — WITHOUT transitive
module dev.nmac.library.core {
    requires dev.nmac.library.api;    // plain requires
    exports dev.nmac.library.core;
}

And library-app only requires core:

// library-app/module-info.java
module dev.nmac.library.app {
    requires dev.nmac.library.core;
}

Any code in library-app that touches the Book type — even indirectly through BookService.findByIsbn() — would produce:

error: package dev.nmac.library.api is not visible
    (package dev.nmac.library.api is declared in module
     dev.nmac.library.api, which is not read by module dev.nmac.library.app)

The fix would be to add requires dev.nmac.library.api to every single consumer of library-core. In a project with dozens of downstream modules, this becomes a maintenance burden.

When a module's public API exposes types from a dependency, that dependency becomes a hidden requirement for every consumer — and without a mechanism to propagate readability, each consumer must discover and declare it on their own.

requires transitive Semantics

The requires transitive directive adds an implied readability edge to the module graph. Any module that reads module A also automatically reads every module that A declares as requires transitive. The transitive dependency is available at both compile time and runtime.

module dev.nmac.library.core {
    requires transitive dev.nmac.library.api;
    exports dev.nmac.library.core;
}

With this declaration, every module that writes requires dev.nmac.library.core automatically gains read access to dev.nmac.library.api. No additional directive is needed in the consumer.

The module graph with implied readability:

library-app ──requires──▶ library-core ──requires transitive──▶ library-api
     │                                                              ▲
     └──────────────── implied readability ─────────────────────────┘

The dashed arrow from library-app to library-api does not exist in any module-info.java file — the module system creates it automatically at resolution time.

Key Insight: requires transitive is a declaration by the module author that says: "my public API is incomplete without this dependency — anyone who uses me will need it too."

When to Use requires transitive

The rule is straightforward: if your module's exported API signatures reference types from a dependency, that dependency should be declared requires transitive. In practice, "signatures" means method return types, parameter types, superclass types, and thrown exception types that appear in your exported packages.

When NOT to Use requires transitive

Not every dependency deserves transitive propagation:

  • Internal implementation details. If a dependency is used only inside non-exported packages, keep it as a plain requires.

  • When consumers should opt in explicitly. Some dependencies carry significant weight — large frameworks, modules with their own transitive trees. Forcing them onto every consumer through transitivity may be undesirable.

  • When the types never appear in your public API. If you use a library internally but your exported methods accept and return only standard JDK types, there is no reason to propagate readability.

A common mistake is marking every dependency as requires transitive "just in case." This over-exposes your module's dependency tree, creating implicit coupling that makes the module graph harder to understand and refactor.

Use requires transitive when your API cannot be used without the dependency, and plain requires when the dependency is an implementation detail.

Real-World JDK Example

The JDK itself uses requires transitive extensively. The java.sql module provides a clear illustration:

// JDK module — java.sql/module-info.java
module java.sql {
    requires transitive java.logging;
    requires transitive java.transaction.xa;
    requires transitive java.xml;

    exports java.sql;
    exports javax.sql;

    uses java.sql.Driver;
}

Why does java.sql declare three transitive dependencies?

  • java.logging — the java.sql.Driver interface defines getParentLogger(), which returns java.util.logging.Logger. Any code working with JDBC drivers needs to read java.logging to use the returned Logger object.

  • java.xml — the java.sql.SQLXML interface represents SQL/XML data. Code that calls ResultSet.getSQLXML() receives a type defined in the java.xml module.

  • java.transaction.xa — the javax.sql.XADataSource and javax.sql.XAConnection interfaces extend types from the java.transaction.xa module.

Without these transitive declarations, every module using JDBC would need to separately require java.logging, java.xml, and java.transaction.xa.

In Practice: library-core and library-api

The full library-core declaration is shown in Declaring Dependencies with requires. The key line is requires transitive dev.nmac.library.api; — every public method on BookService accepts or returns Book and BookRepository types from library-api (see the BookService source in that section), so without implied readability every consumer would need an explicit requires dev.nmac.library.api.

The App Module — No Explicit API Dependency

Thanks to requires transitive, library-app carries no explicit requires dev.nmac.library.api (the full descriptor is in Putting It All Together). The import resolves through implied readability alone:

// library-app — LibraryApp.java (excerpt)
import dev.nmac.library.api.Book;          // resolved via implied readability
import dev.nmac.library.core.BookService;

Book found = service.findByIsbn("978-0134685991").orElseThrow();
System.out.println("Found: " + found.title() + " by " + found.author());
Found: Effective Java by Joshua Bloch

Source code: library-core/src/main/java/ and library-app/src/main/java/.

Module Graph Implications

Implied readability creates chains. If module A declares requires transitive B, and module C declares requires A, then C automatically reads both A and B. If B itself declares requires transitive D, then C reads A, B, and D — all through a single requires A.

This chaining property is powerful but demands intentional design. Each requires transitive widens the effective API surface of the declaring module.

Transitivity Does Not Imply Exporting

An important distinction: requires transitive grants readability, not exports. The transitive module's own exports directives still govern which packages are visible. The two mechanisms — readability and accessibility — work together but are independent.

When Consumers Should Require Explicitly

If a downstream module uses types from the transitive dependency extensively — not just receiving them from method returns but actively constructing them and depending on their full API — it may be clearer to add an explicit requires for documentation purposes. The module system does not require it, but an explicit declaration signals intent.

Implied readability simplifies the consumer's module descriptor, but the simplification comes at the cost of implicit coupling — use requires transitive where the API genuinely requires it, and plain requires for everything else.


Qualified Exports and Opens

Both exports and opens support a qualified form: adding a to clause restricts visibility to a named list of target modules. Every other module sees the package as if it does not exist — no compile access, no runtime access, no indication it is there.

Standard vs Qualified Directives

// Unqualified — accessible to ALL modules
exports dev.nmac.library.core;

// Qualified — accessible ONLY to named modules
exports dev.nmac.library.core.search to dev.nmac.library.app;
// Unqualified — any module can reflect into this package
opens dev.nmac.library.data;

// Qualified — only the named module can reflect into this package
opens dev.nmac.library.data to com.google.gson;

The to clause turns a public export into a private channel between cooperating modules — the module system's equivalent of granting access to a trusted collaborator while keeping the door locked for everyone else.

Qualified Exports (exports...to)

A qualified export restricts compile-time and runtime access to a comma-separated list of target modules. Every other module in the system — even ones that explicitly requires the exporting module — cannot see the package at all.

// library-core — full declaration in Declaring Dependencies with requires
module dev.nmac.library.core {
    // requires transitive dev.nmac.library.api  (see Implied Readability)

    // Public API — accessible to all modules
    exports dev.nmac.library.core;

    // Qualified export — only library-app can access this package
    exports dev.nmac.library.core.search to dev.nmac.library.app;

    // dev.nmac.library.core.internal is NOT exported at all
}

Three levels of visibility in a single module descriptor:

  1. exports dev.nmac.library.core — open to the world

  2. exports dev.nmac.library.core.search to dev.nmac.library.app — open to one friend

  3. dev.nmac.library.core.internal — not exported, fully encapsulated

Multiple target modules are separated by commas:

exports dev.nmac.library.core.search
    to dev.nmac.library.app,
       dev.nmac.library.admin;

The target modules do not need to exist at compile time. If a listed target cannot be found, the compiler emits a warning — not an error.

Key Insight: Qualified exports create a middle ground between "fully public" and "fully encapsulated." The package exists for its intended consumers and is invisible to everyone else — enforced by the compiler and the JVM, not by convention.

Use Cases for Qualified Exports

The "Friend Modules" Pattern

In C++, the friend keyword grants one class access to another's private members. Java has never had an equivalent — until modules. Qualified exports let cooperating modules share implementation details without exposing them to the rest of the system.

The JDK's Own Usage

The java.xml module demonstrates this pattern at scale. Running java --describe-module java.xml reveals seven qualified exports — all targeting a single consumer:

qualified exports com.sun.org.apache.xpath.internal to java.xml.crypto
qualified exports com.sun.org.apache.xml.internal.utils to java.xml.crypto
...

The java.xml.crypto module needs access to internal XML parsing infrastructure to implement XML digital signatures. These are implementation packages that no application code should ever depend on. Qualified exports make the sharing explicit and safe.

Common Scenarios

  • Multi-module libraries — a library split into core, util, and impl modules can share internal helpers between its own modules without leaking them to consumers

  • Framework internals — a framework can expose implementation hooks to its own extension modules while keeping them hidden from application code

  • Testing modules — a test module can receive qualified access to internal packages for white-box testing without breaking encapsulation for production consumers

Qualified Opens (opens...to)

Just as exports has a qualified form, so does opens. The opens...to directive restricts deep reflective access to specific named modules:

module dev.nmac.library.data {
    requires dev.nmac.library.api;

    exports dev.nmac.library.data;

    // Only Gson can reflect into this package
    opens dev.nmac.library.data to com.google.gson;
}

In practice, qualified opens are less common than qualified exports. Listing specific framework modules in an opens...to clause creates tight coupling between your module descriptor and your dependency choices. If you switch from Gson to Jackson, you need to update module-info.java. For this reason, most modules that need to support reflection either use unqualified opens for specific packages or declare themselves as open module.

Qualified opens make sense in one scenario: when you control both the reflecting module and the reflected-into module, and you want to limit reflective access to exactly that pair.

Use opens...to only when you know and control the exact module that will perform reflection. For framework interoperability, prefer unqualified opens — tying your module descriptor to a specific framework version creates unnecessary brittleness.

In Practice: BookSearchEngine

The Library project uses a qualified export to share search functionality between library-core and library-app. The BookSearchEngine class lives in the dev.nmac.library.core.search package — a package that only the application module can access.

The Module Descriptor

The full library-core declaration is in Declaring Dependencies with requires. The relevant directive:

// library-core — qualified export restricts search access to library-app only
exports dev.nmac.library.core.search to dev.nmac.library.app;

The Search Engine

// library-core — BookSearchEngine.java
package dev.nmac.library.core.search;

public class BookSearchEngine {

    private final BookRepository repository;

    public BookSearchEngine(BookRepository repository) {
        this.repository = repository;
    }

    public List<Book> search(String keyword) {
        String lower = keyword.toLowerCase();
        return repository.findAll().stream()
                .filter(book ->
                    book.title().toLowerCase().contains(lower) ||
                    book.author().toLowerCase().contains(lower))
                .toList();
    }

    public List<Book> findByYearRange(int fromYear, int toYear) {
        return repository.findAll().stream()
                .filter(book -> book.year() >= fromYear && book.year() <= toYear)
                .toList();
    }
}

Using It from the Target Module

Because dev.nmac.library.app is named in the to clause, LibraryApp can import and use BookSearchEngine without restriction:

// library-app — LibraryApp.java (excerpt)
import dev.nmac.library.core.search.BookSearchEngine;

var searchEngine = new BookSearchEngine(repository);
var results = searchEngine.search("Bloch");
System.out.println("Search for 'Bloch': " + results.size() + " books found");
Search for 'Bloch': 2 books found

What Happens from a Non-Target Module

If a hypothetical library-reporting module tried to use BookSearchEngine:

// library-reporting — this will NOT compile
import dev.nmac.library.core.search.BookSearchEngine;
error: package dev.nmac.library.core.search is not visible
    (package dev.nmac.library.core.search is declared in module
     dev.nmac.library.core, which does not export it to module
     dev.nmac.library.reporting)

Notice the error message: "which does not export it to module dev.nmac.library.reporting." The compiler knows the package exists and that it is qualified-exported — it just tells the offending module that it is not on the guest list.

Source code: The qualified export is defined in library-core/src/main/java/module-info.java. The search engine is at library-core/src/main/java/dev/nmac/library/core/search/BookSearchEngine.java.

From the target module's perspective, a qualified export behaves identically to an unqualified one — no special imports, no annotations, no ceremony. The restriction is entirely on the exporting side.

Compilation Rules

Missing Targets Produce Warnings, Not Errors

If a target module listed in the to clause does not exist on the module path, the compiler emits a warning — not an error. This allows modules to be compiled independently and assembled later.

No Duplicate Exports

The same package cannot appear in both a qualified and an unqualified exports directive within the same module descriptor:

// This will NOT compile
module dev.nmac.library.core {
    exports dev.nmac.library.core.search;                      // unqualified
    exports dev.nmac.library.core.search to dev.nmac.library.app;  // qualified
}
error: duplicate exports: dev.nmac.library.core.search

You must choose one or the other. If you need broad access with exceptions, the answer is an unqualified export — the module system does not support "export to everyone except module X."

The compiler enforces clarity in module descriptors. A package is either exported to everyone, exported to a named list, or not exported at all — each package has exactly one visibility rule.


Building and Running — Module Path, Modular JARs, Resolution

Modular JARs

A JAR file with module-info.class in its root is a modular JAR. But having module-info.class does not automatically activate the module system — what matters is where the JAR is placed. Put it on the module path and the JVM treats it as a module, enforcing all declared access rules. Put it on the classpath and the module-info.class is silently ignored — the JAR behaves exactly like a pre-Java 9 JAR with no encapsulation.

The Module Path (--module-path)

Before the module system, the JVM had one concept for locating code: the classpath — a flat list of JARs with no identity, no declared dependencies, and no enforced boundaries. The module path replaces this for modular code. Each JAR on the module path is recognized by its module name (from module-info.java), and only declared relationships between modules are permitted.

To launch a modular application you supply two flags: --module-path with the list of JARs, and --module with the entry point:

java \
  --module-path \
    library-api/build/libs/library-api.jar:\
    library-core/build/libs/library-core.jar:\
    library-data/build/libs/library-data.jar:\
    library-format-provider/build/libs/library-format-provider.jar:\
    library-analytics/build/libs/library-analytics.jar:\
    library-app/build/libs/library-app.jar \
  --module dev.nmac.library.app/dev.nmac.library.app.LibraryApp

--module-path (short: -p) is where the JVM looks for modules. --module (short: -m) names the root module and its main class as <module-name>/<fully-qualified-class> — no Main-Class entry in the manifest is needed.

In our Library project, six JARs go on the module path — one per subproject. Notice that library-format-provider is listed even though library-app's module-info.java has no requires dev.nmac.library.format.provider. Service providers are discovered through the module path, not through requires edges. The uses dev.nmac.library.api.BookFormatter declaration in library-app tells the JVM to scan for providers at resolution time.

To generate the JARs: gradle build. To skip the manual command entirely: gradle :library-app:run.

Module Resolution

Before executing a single line of application code, the JVM performs module resolution: it starts from the root module (dev.nmac.library.app) and recursively follows every requires directive to build a complete module graph. If any required module is absent from the module path, a split package is detected, or a circular dependency exists, the JVM aborts with a clear error — not a NoClassDefFoundError in production six months later.

Add --show-module-resolution to watch the graph being built:

java --show-module-resolution \
  --module-path ... \
  --module dev.nmac.library.app/dev.nmac.library.app.LibraryApp
root dev.nmac.library.app file:///.../library-app.jar
dev.nmac.library.app requires dev.nmac.library.core file:///.../library-core.jar
dev.nmac.library.app requires dev.nmac.library.data file:///.../library-data.jar
dev.nmac.library.core requires transitive dev.nmac.library.api file:///.../library-api.jar
dev.nmac.library.app uses dev.nmac.library.api.BookFormatter
dev.nmac.library.format.provider provides dev.nmac.library.api.BookFormatter

Each line is one edge in the module graph. A few things worth noting in our example:

  • library-api appears even though library-app never wrote requires dev.nmac.library.api — it enters the graph via requires transitive in library-core, which re-exports it

  • library-format-provider appears under provides, not requires — the JVM found it as a service provider through the module path, not through any dependency declaration

  • If a module were missing from the path, this is the point where the JVM would abort — before main() is ever called

Expected Output

Running gradle :library-app:run (analytics is compileOnly in this build, so absent at runtime):

=== Java Module System — Library Application ===

Books in library: 3
Search for 'Bloch': 2 books found

Available formatters (via ServiceLoader):
  [plain-text] "Effective Java" by Joshua Bloch (2018) [ISBN: 978-0134685991]
  [json] {
  "title": "Effective Java",
  "author": "Joshua Bloch",
  "isbn": "978-0134685991",
  "year": 2018
}

Serialized via reflection: title=Effective Java, author=Joshua Bloch, isbn=978-0134685991, year=2018
Analytics module not available (optional dependency)

=== Application completed successfully ===

The library-analytics module is declared compileOnly in the build, so the Gradle run task does not place it on the module path. The module system skips it during resolution — requires static does not force presence at runtime — and the application falls through to the NoClassDefFoundError catch block. This is the behavior demonstrated in Optional Dependencies.

To run with analytics active, add it as runtimeOnly in library-app/build.gradle or pass --add-modules dev.nmac.library.analytics when running the JAR directly.

java.base — The Implicit Dependency

Every module implicitly depends on java.base, which contains java.lang, java.util, java.io, and other foundational packages. You never need to write requires java.base — the compiler adds it automatically to every module descriptor.


What We Covered

This article walked through the full directive set of the Java Module System using a real seven-module project:

  • exports and requires — the foundation of explicit, compiler-verified API boundaries

  • Strong encapsulation — why public is no longer sufficient for cross-module access

  • opens and open module — how reflection-based frameworks integrate with the module system

  • requires static — optional dependencies with the defensive coding patterns they require

  • requires transitive — propagating readability through the module graph when your API exposes dependency types

  • Qualified exports...to and opens...to — private channels between cooperating modules

  • Module path, resolution, and modular JARs — how the JVM builds the module graph at startup

The next article goes deeper into the less visible parts of the module system: unnamed and automatic modules (how unmodularized JARs fit in), JDK internal encapsulation, and the command-line escape hatches — --add-opens, --add-exports, and --add-reads — that exist for migration and interoperability.

Next: The Java Module System — Deep Dive (coming soon)

More from this blog