The Java Platform Module System — Fundamentals

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:
The Java Module System — Fundamentals (this article) — module declarations, directives, encapsulation, and how the pieces fit together
The Java Module System — Deep Dive — unnamed and automatic modules, JDK internal encapsulation, and command-line escape hatches
Java Modules and Docker — building minimal container images with
jlinkand custom runtimesBuilding a Service with Java Modules — designing a plugin architecture using
ServiceLoaderand 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:
Strong encapsulation — hide internal implementation details so that only explicitly exported packages are accessible to other modules
Reliable configuration — detect missing dependencies, duplicate modules, and package conflicts at startup rather than at runtime
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 withBook,BookRepository, andBookFormatterinterfaces. Run the tests withgradle :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:versioncoordinates, downloads JARs from repositories, manages transitive dependency trees, handles version conflictsmodule-info.java — defines
module-namerelationships 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:
requires transitive— depends on the API module and re-exports it, so any module that requireslibrary.coreautomatically gets access tolibrary.apitypesUnqualified
exports— makesdev.nmac.library.coreaccessible to all modulesQualified
exports...to— restrictsdev.nmac.library.core.searchto onlydev.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 internalIsbnValidator. Run the tests withgradle :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:
The type is
publicIts package is
exports-ed by module BModule A
requiresmodule 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 importingIsbnValidatorfrom 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 becauselibrary.coredeclaresrequires transitive dev.nmac.library.api, the app automatically gets access to all API types. No explicitrequires dev.nmac.library.apiis needed.requires dev.nmac.library.data— a data module withopensfor 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 discoverBookFormatterimplementations viaServiceLoaderat 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 executesLibraryApp.main()on the module path with all six modules resolved. The full source is atlibrary-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
ServiceLoaderAPI, 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/andlibrary-reflect-demo/— the module declarations andReflectionDemowith 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-analyticsis 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.javaandlibrary-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— thejava.sql.Driverinterface definesgetParentLogger(), which returnsjava.util.logging.Logger. Any code working with JDBC drivers needs to readjava.loggingto use the returnedLoggerobject.java.xml— thejava.sql.SQLXMLinterface represents SQL/XML data. Code that callsResultSet.getSQLXML()receives a type defined in thejava.xmlmodule.java.transaction.xa— thejavax.sql.XADataSourceandjavax.sql.XAConnectioninterfaces extend types from thejava.transaction.xamodule.
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/andlibrary-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:
exports dev.nmac.library.core— open to the worldexports dev.nmac.library.core.search to dev.nmac.library.app— open to one frienddev.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, andimplmodules can share internal helpers between its own modules without leaking them to consumersFramework 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 atlibrary-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-apiappears even thoughlibrary-appnever wroterequires dev.nmac.library.api— it enters the graph viarequires transitiveinlibrary-core, which re-exports itlibrary-format-providerappears underprovides, notrequires— the JVM found it as a service provider through the module path, not through any dependency declarationIf 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:
exportsandrequires— the foundation of explicit, compiler-verified API boundariesStrong encapsulation — why
publicis no longer sufficient for cross-module accessopensandopen module— how reflection-based frameworks integrate with the module systemrequires static— optional dependencies with the defensive coding patterns they requirerequires transitive— propagating readability through the module graph when your API exposes dependency typesQualified
exports...toandopens...to— private channels between cooperating modulesModule 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)



