Part 1 of 4 — A practical introduction to JPMS for senior developers who have never touched module-info.java.
Java 9 shipped the Java Platform Module System nine years ago. Most enterprise projects still run on the classpath — Spring Boot, Jakarta EE, and the major frameworks support modules but do not require them. Yet modules solve real problems: strong encapsulation across JAR boundaries, reliable dependency resolution at startup, and the foundation for custom runtimes with jlink. This article covers the fundamentals — module-info.java, core directives, and strong encapsulation — through a working seven-module Library project.
Why This Matters
For decades, the classpath treated every JAR as a flat search path. Duplicate packages? Silently merged. Missing dependencies? Discovered at runtime. Every public class in every JAR was accessible to all code — no way to hide implementation details across module boundaries.
Oracle faced the same problem inside the JDK itself. By 2008 the platform had grown into a 150 MB monolith where internal APIs like sun.misc.Unsafe were depended on by half the ecosystem. Project Jigsaw took nine years to ship with Java 9, solving three problems:
- Strong encapsulation — only explicitly exported packages are accessible
- Reliable configuration — missing modules and split packages fail at startup
- Scalable platform — ship only the JDK parts your application needs
In 2026, module adoption among application developers remains low. Industry reports track Java versions and frameworks but not module usage. That does not mean modules are useless — their value is concentrated in specific scenarios: library design, plugin architectures, framework internals, and container optimization.
What Is the Module System?
A module is a named, self-describing unit that groups packages and declares its dependencies and exports in a single file: module-info.java. The compiler produces module-info.class, and a JAR containing it is a modular JAR.
Before Java 9 (classpath):
// Any public class in any JAR is accessible everywhere
import com.example.internal.IsbnValidator; // works if the JAR is on the classpath
After Java 9 (module path):
// library-api/src/main/java/module-info.java
module dev.nmac.library.api {
exports dev.nmac.library.api;
}
Everything not explicitly exports-ed is encapsulated — regardless of Java access modifiers.
Simple Example
The simplest module in the Library project exports a single package with a Book record:
// library-api — module-info.java
module dev.nmac.library.api {
exports dev.nmac.library.api;
}
// 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");
}
}
}
var book = new Book("Effective Java", "Joshua Bloch", "978-0134685991", 2018);
System.out.println(book.title() + " by " + book.author());
// Effective Java by Joshua Bloch
Any module that requires dev.nmac.library.api can use Book. Any module that does not — cannot.
Core Directives
| Directive | Purpose |
|---|---|
requires |
Declares a dependency on another module |
exports |
Makes a package accessible to other modules |
opens |
Allows deep reflective access at runtime |
requires static |
Optional dependency — required at compile time, optional at runtime |
requires transitive |
Propagates readability to consumers of your module |
uses |
Declares a service consumed via ServiceLoader
|
provides...with |
Registers a service implementation |
exports...to / opens...to
|
Qualified visibility — restricted to named modules |
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. public no longer means "accessible everywhere."
For code in module A to access a type in module B, all three must hold:
- The type is
public - Its package is
exports-ed by module B - Module A
requiresmodule B (directly or transitively)
More Advanced Examples
Strong Encapsulation
IsbnValidator lives in a non-exported internal package. It is public, but other modules cannot import it:
// 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)
requires transitive — Implied Readability
When library-core exposes Book types in its public API, consumers need access to library-api too:
// library-core — 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;
}
library-app only writes requires dev.nmac.library.core — it automatically gains access to library-api types through implied readability.
opens — Reflection Across Module Boundaries
Frameworks like Jackson and Hibernate need setAccessible(true) on private fields. exports alone is not enough:
// library-data — module-info.java
module dev.nmac.library.data {
requires dev.nmac.library.api;
exports dev.nmac.library.data; // compile-time access
opens dev.nmac.library.data; // runtime reflective access
}
Without opens, the JVM throws InaccessibleObjectException at runtime.
Service Discovery — uses and provides...with
Decouple consumers from implementations without a requires edge:
// library-app — consumer
module dev.nmac.library.app {
uses dev.nmac.library.api.BookFormatter;
}
// library-format-provider — provider (no exports needed)
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;
}
ServiceLoader<BookFormatter> formatters = ServiceLoader.load(BookFormatter.class);
for (BookFormatter f : formatters) {
System.out.println("[" + f.name() + "] " + f.format(book));
}
Optional Dependencies — requires static
module dev.nmac.library.app {
requires static dev.nmac.library.analytics; // optional at runtime
}
The application compiles against analytics but starts whether or not the module is on the module path — with defensive NoClassDefFoundError handling at runtime.
Classpath vs Module Path
| Aspect | Classpath | Module Path |
|---|---|---|
| Dependency declaration | Implicit (JARs on path) | Explicit (requires in module-info.java) |
| Public class access | All public classes visible |
Only exports-ed packages visible |
| Missing dependency |
NoClassDefFoundError at runtime |
Fails at startup during resolution |
| Split packages | Silently merged | Detected and rejected |
| Reflection into internals | Always allowed | Requires opens or --add-opens
|
module-info.class |
Ignored | Enforced |
Real-World Use Cases
- Library API design — draw clear boundaries between public API and internal implementation
-
Plugin architectures —
uses/provides...withwithServiceLoader, no compile-time coupling -
Framework integration —
openspackages for Jackson, Hibernate, or Gson reflection -
Optional features —
requires staticfor analytics, monitoring, or platform-specific modules -
Custom JDK runtimes —
jlinkto ship minimal container images (covered in Part 3) - Migration safety — detect split packages and missing modules before production
Read the Full Article
Discover much more in Part 1: Fundamentals on blog.9mac.dev:
- Project Jigsaw history and adoption data for 2026
- Seven-module Library project walkthrough with architecture diagram
- Full
module-info.javareference for every directive - Qualified exports and the "friend modules" pattern
-
open modulevs per-packageopens - Module path, modular JARs, and
--show-module-resolution - Building and running from the command line
- Complete expected output from
gradle :library-app:run
GitHub Repository
All code examples are ready to clone and run:
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
gradle build
gradle :blog-post-examples:java:2026-07-13-java9-module-system:library-app:run
Requires Java 25+ and Gradle 9.
Next in the series: Part 2 — Deep Dive (unnamed and automatic modules, JDK internal encapsulation, and command-line escape hatches)
Top comments (0)