Skip to content

Java Module System (JPMS) — Complete Guide

DodaTech Updated 2026-06-20 10 min read

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.

  1. Start with --add-exports and --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.

  2. Create module-info.java for your code: Start with the simplest possible declaration — exports your public packages, requires java.base (implicit, but may need more).

  3. Handle unnamed module: All JARs on the classpath form the unnamed module. Your module can require it, but unnamed modules cannot require named modules. Move JARs to the module path gradually.

  4. Fix split packages: If two JARs contain the same package, rename one or merge them. JPMS doesn't allow split packages.

  5. Add opens for Reflection: Frameworks like Hibernate, Spring, and Jackson need reflective access. Add opens directives or use --add-opens temporarily.

# 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 opens directives
  • 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

  1. What is the difference between exports and opens in module-info.java?
  2. How does JPMS prevent JAR hell?
  3. What is a qualified export (exports ... to)?
  4. How does ServiceLoader discover service providers?
  5. Why does migrating to modules require handling split packages?

Answers:

  1. exports allows compile-time and runtime access to public types in a package (import and instantiate). opens allows reflective access to private members (for frameworks like Hibernate). Without opens, setAccessible(true) throws InaccessibleObjectException.
  2. 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.
  3. 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 have opens).
  4. ServiceLoader.load(Interface.class) reads META-INF/services/interface.full.name files, which list provider implementations. JPMS's provides X with Y directive generates these files automatically. The loader instantiates each provider and returns them as an iterable.
  5. 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:

  1. A com.dodatech.reporting.spi module defining a ReportGenerator interface with String generate(String data) method
  2. A com.dodatech.reporting.html module that implements ReportGenerator with HTML output
  3. A com.dodatech.reporting.json module that implements it with JSON output
  4. A com.dodatech.app module that uses ServiceLoader to discover and run both generators
  5. Each provider module uses provides ... with ... directive
  6. The app module uses uses ... directive

Real-World Task: Modular Security Scanner

Build a security scanner using JPMS modules:

  1. Core module: exports scanner API, keeps signature database internal
  2. Plugin module: requires core, provides a custom heuristic analyzer
  3. App module: uses analyzer services, runs all, aggregates results
  4. Use exports ... to so 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.

What Java version do I need for JPMS?

The Java Module System was introduced in Java 9. Java 17 (LTS) is the recommended minimum for production use. Java 8 and earlier do not support modules — you must use the classpath instead.

Can I use modules with existing libraries that don't have module-info.java?

Yes. Libraries on the classpath form the unnamed module, which can read all named modules. Your named module can also read the unnamed module (using requires isn't necessary — automatic module access is granted). Libraries JARs in META-INF can also be treated as automatic modules by placing them on the module path with --module-path.

What happens if I use Reflection on a module without opens?

The JVM throws InaccessibleObjectException and blocks the reflective access. This prevents libraries from reading private fields, calling private methods, or instantiating classes via private constructors without explicit permission from the module owner.

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