Java Module System (JPMS) — Complete Guide
In this tutorial, you'll learn about Java Module System (JPMS). We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
The Java Module System (JPMS) groups packages into modules with explicit dependencies and enforced APIs, replacing the classpath with reliable module boundaries.
Why the Module System Matters
Before modules, any class could access any other class on the classpath. There was no way to hide internal implementation, no reliable dependency declaration, and "JAR hell" was common (two versions of the same library conflicting at runtime). JPMS fixes this: modules declare what they export and what they require. The JVM enforces these boundaries at startup and runtime. DodaTech's Doda Browser migrated to modules in Java 17, reducing startup time by 40% and eliminating classpath conflicts entirely. Java I/O and annotations both interact with the module system's access control.
Learning Path
graph LR
A[Java Basics] --> B[Package & Classpath]
B --> C[Module Fundamentals]
C --> D[module-info.java]
D --> E[Exports & Requires]
E --> F[Opens & Reflection]
F --> G[Services & Providers]
G --> H[Migration Strategy]
style D fill:#f59e0b,color:#fff,stroke-width:3px
What Is a Module?
A module is a named, self-describing collection of packages and resources. Every module has a module-info.java file at its root that declares:
- Module name — globally unique (reversed domain, like packages)
- Exports — which packages are visible to other modules
- Requires — which modules this module depends on
- Opens — which packages allow reflective access
- Provides/Uses — service provider interfaces
Think of modules like buildings with locked doors. Without modules, every building's doors are open. With modules, each building has a guard (the JVM) who checks: "Do you have permission to enter this room? Are you allowed to use this service?"
Your First Module
Create two directories side by side:
├── com.dodatech.scanner/
│ ├── module-info.java
│ └── com/dodatech/scanner/
│ ├── FileScanner.java
│ └── internal/
│ └── SignatureMatcher.java
└── com.dodatech.app/
├── module-info.java
└── com/dodatech/app/
└── Main.java
The Scanner Module
// com.dodatech.scanner/module-info.java
module com.dodatech.scanner {
exports com.dodatech.scanner;
// SignatureMatcher stays hidden (not exported)
}
package com.dodatech.scanner;
public class FileScanner {
private final SignatureMatcher matcher = new SignatureMatcher();
public ScanResult scan(String filePath) {
System.out.println("Scanning: " + filePath);
return matcher.match(filePath)
? ScanResult.THREAT_DETECTED
: ScanResult.CLEAN;
}
public enum ScanResult { CLEAN, THREAT_DETECTED }
}
package com.dodatech.scanner.internal;
// This class is NOT exported — invisible to other modules
public class SignatureMatcher {
private static final byte[][] MALICIOUS_SIGNATURES = {
{0x4D, 0x5A}, // MZ header (EXE)
{0x50, 0x4B, 0x03, 0x04}, // ZIP header
{0x7F, 0x45, 0x4C, 0x46} // ELF header
};
public boolean match(String filePath) {
System.out.println(" [internal] Checking signatures...");
return false; // simplified for example
}
}
The App Module
// com.dodatech.app/module-info.java
module com.dodatech.app {
requires com.dodatech.scanner;
}
package com.dodatech.app;
import com.dodatech.scanner.FileScanner;
// import com.dodatech.scanner.internal.SignatureMatcher;
// ↑ This line would FAIL to compile — SignatureMatcher is not exported
public class Main {
public static void main(String[] args) {
FileScanner scanner = new FileScanner();
FileScanner.ScanResult result = scanner.scan("/tmp/suspicious.exe");
System.out.println("Result: " + result);
}
}
Compile and run:
javac -d out/com.dodatech.scanner \
com.dodatech.scanner/module-info.java \
com.dodatech.scanner/com/dodatech/scanner/FileScanner.java \
com.dodatech.scanner/com/dodatech/scanner/internal/SignatureMatcher.java
javac -d out/com.dodatech.app \
--module-path out/com.dodatech.scanner \
com.dodatech.app/module-info.java \
com.dodatech.app/com/dodatech/app/Main.java
java --module-path out/com.dodatech.scanner:out/com.dodatech.app \
--module com.dodatech.app/com.dodatech.app.Main
Expected output:
Scanning: /tmp/suspicious.exe
[internal] Checking signatures...
Result: CLEAN
SignatureMatcher is in a non-exported package. Any attempt to import it from another module causes a compile error. This is strong Encapsulation — internal implementation stays internal. Durga Antivirus Pro uses this to isolate its signature database from third-party plugins.
Exporting with Filters
You can restrict exports to specific modules only:
module com.dodatech.scanner {
exports com.dodatech.scanner;
// Only com.dodatech.app can see this package
exports com.dodatech.scanner.integration to com.dodatech.app;
// Anyone can use this (public API)
exports com.dodatech.scanner.api;
}
exports ... to creates a qualified export — only the named modules can access those packages. This is useful for internal APIs that partner modules need but external code shouldn't touch. DodaTech uses this for its plugin API: plugin modules get access to integration packages that regular app code cannot see.
Opens — Reflection Access
Remember reflection from the annotations guide? In Java 9+, Reflection cannot access private members of classes in other modules unless the module explicitly permits it.
module com.dodatech.model {
exports com.dodatech.model;
// Allow any module to reflect on these packages
opens com.dodatech.model;
// Allow only the serializer module
opens com.dodatech.model.internal to com.dodatech.serializer;
}
Without opens, code like this fails at runtime:
// In com.dodatech.app — accessing com.dodatech.model
Field f = User.class.getDeclaredField("password");
f.setAccessible(true); // InaccessibleObjectException without opens
Expected error without opens:
Exception in thread "main" java.lang.reflect.InaccessibleObjectException:
Unable to make field private java.lang.String com.dodatech.model.User.password
accessible: module com.dodatech.model does not "opens com.dodatech.model"
This is a major security improvement. Before modules, any library could use Reflection to read private fields (like passwords). JPMS blocks this by default.
Services — The ServiceLoader Pattern
JPMS has a built-in service mechanism for plugin architectures — no external DI framework needed.
// SPI module: com.dodatech.spi
package com.dodatech.spi;
public interface FileAnalyzer {
String name();
boolean analyze(String filePath);
double confidenceScore();
}
// Provider module: com.dodatech.plugin.malware
module com.dodatech.plugin.malware {
requires com.dodatech.spi;
provides com.dodatech.spi.FileAnalyzer
with com.dodatech.plugin.malware.MalwareAnalyzer;
}
package com.dodatech.plugin.malware;
import com.dodatech.spi.FileAnalyzer;
public class MalwareAnalyzer implements FileAnalyzer {
@Override
public String name() { return "Malware Signature Scanner"; }
@Override
public boolean analyze(String filePath) {
System.out.println(" >> " + name() + " analyzing " + filePath);
return filePath.endsWith(".exe");
}
@Override
public double confidenceScore() { return 0.95; }
}
// Consumer module: com.dodatech.app
module com.dodatech.app {
requires com.dodatech.spi;
uses com.dodatech.spi.FileAnalyzer;
}
package com.dodatech.app;
import com.dodatech.spi.FileAnalyzer;
import java.util.ServiceLoader;
public class PluginRunner {
public static void main(String[] args) {
ServiceLoader<FileAnalyzer> analyzers =
ServiceLoader.load(FileAnalyzer.class);
for (FileAnalyzer analyzer : analyzers) {
System.out.println("Plugin: " + analyzer.name());
boolean threat = analyzer.analyze("/tmp/setup.exe");
System.out.println(" Threat: " + threat +
" (confidence: " + analyzer.confidenceScore() + ")");
}
}
}
Expected output:
Plugin: Malware Signature Scanner
>> Malware Signature Scanner analyzing /tmp/setup.exe
Threat: true (confidence: 0.95)
ServiceLoader discovers all providers via META-INF/services/ files (generated automatically by the compiler from provides directives). Doda Browser uses this to load ad-blocker, VPN, and password manager extensions — each is a separate module discovered at startup with zero configuration.
Common Errors in JPMS
| Error | Cause | Fix |
|---|---|---|
package X is not visible |
Imported package isn't exported by its module | Add exports in the provider's module-info.java |
module X not found |
Module not on module path | Use --module-path and ensure name matches directory/JAR |
InaccessibleObjectException |
Reflective access to module without opens |
Add opens directive or use --add-opens JVM flag |
ClassNotFoundException for services |
Provider not registered with provides directive |
Add provides X with Y in provider's module-info.java |
module X reads package Y from both Z and W |
Split package (same package in multiple modules) | Merge packages or rename; split packages are illegal in JPMS |
requires transitive cycle |
Circular module dependencies | Introduce an SPI module that both modules depend on |
javac: module name mismatch |
Directory/module path doesn't match declared module name | Ensure directory or JAR name matches module-info.java declaration |
Migration Strategy: Classpath to Module Path
Migrating an existing project to modules is a multi-step process. Don't do it all at once.
Start with
--add-exportsand--add-opens: Run your app with these JVM flags to discover what Reflection breaks. This gives you a working app while you plan the migration.Create
module-info.javafor your code: Start with the simplest possible declaration —exportsyour public packages,requires java.base(implicit, but may need more).Handle unnamed module: All JARs on the classpath form the unnamed module. Your module can
requireit, but unnamed modules cannotrequirenamed modules. Move JARs to the module path gradually.Fix split packages: If two JARs contain the same package, rename one or merge them. JPMS doesn't allow split packages.
Add
opensfor Reflection: Frameworks like Hibernate, Spring, and Jackson need reflective access. Addopensdirectives or use--add-openstemporarily.
# Migration helper flags
java --add-exports java.base/com.example.internal=ALL-UNNAMED \
--add-opens java.base/java.lang=ALL-UNNAMED \
--illegal-access=warn \
-jar myapp.jar
--illegal-access=warn (Java 9–16) logs reflective access violations without failing — perfect for discovering what needs opens directives. In Java 17+, this flag is removed and violations throw exceptions by default.
Security Angle: Encapsulation as Security
JPMS's strongest security feature is default Encapsulation. Before Java 9, any library could:
- Read private fields via Reflection (data exfiltration)
- Access internal APIs that changed between versions (portability breakage)
- Load classes from arbitrary packages (classloader attacks)
With JPMS:
- Internal packages are invisible unless explicitly exported
- Reflective access requires
opensdirectives - A module's dependencies are declared and verified at startup
DodaTech's Durga Antivirus Pro uses JPMS to isolate its scanning engine from plugin code. Plugin modules cannot access the signature database module's internal packages, preventing both accidental misuse and malicious data extraction.
Practice Questions
- What is the difference between
exportsandopensin module-info.java? - How does JPMS prevent JAR hell?
- What is a qualified export (
exports ... to)? - How does
ServiceLoaderdiscover service providers? - Why does migrating to modules require handling split packages?
Answers:
exportsallows compile-time and runtime access to public types in a package (import and instantiate).opensallows reflective access to private members (for frameworks like Hibernate). Withoutopens,setAccessible(true)throwsInaccessibleObjectException.- JPMS requires unique module names and prevents split packages. Two modules cannot contain the same package. Additionally, each module declares its dependencies explicitly, so the JVM can detect version conflicts before the application starts.
- A qualified export restricts package visibility to specific modules only:
exports com.dodatech.internal to com.dodatech.plugin. Other modules cannot access that package — not even through Reflection (unless they also haveopens). ServiceLoader.load(Interface.class)readsMETA-INF/services/interface.full.namefiles, which list provider implementations. JPMS'sprovides X with Ydirective generates these files automatically. The loader instantiates each provider and returns them as an iterable.- Split packages (same package in two modules) are illegal in JPMS because the module system cannot determine which module owns the package. Fix by merging packages into one module or renaming packages to eliminate conflicts.
Challenge
Create a multi-module project with:
- A
com.dodatech.reporting.spimodule defining aReportGeneratorinterface withString generate(String data)method - A
com.dodatech.reporting.htmlmodule that implementsReportGeneratorwith HTML output - A
com.dodatech.reporting.jsonmodule that implements it with JSON output - A
com.dodatech.appmodule that usesServiceLoaderto discover and run both generators - Each provider module uses
provides ... with ...directive - The app module uses
uses ...directive
Real-World Task: Modular Security Scanner
Build a security scanner using JPMS modules:
- Core module:
exportsscanner API, keeps signature databaseinternal - Plugin module:
requirescore,providesa custom heuristic analyzer - App module:
usesanalyzer services, runs all, aggregates results - Use
exports ... toso plugins see integration APIs but app code doesn't
This mirrors exactly how Durga Antivirus Pro's module-based plugin system works — third-party security vendors write analyzer modules that the core engine discovers and executes in a sandboxed module context.
Related tutorials: Java Annotations & Reflection — Complete Guide, Java Build Tools — Maven & Gradle Guide
Next lesson: Eclipse MicroProfile — Cloud-Native Java
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro