Contents

Before Java 9, the classpath was a flat pile of JARs. Any class in any JAR could access any public class in any other JAR — there was no way to hide internal implementation packages from outside consumers. This led to widespread accidental dependencies on internal JDK APIs like sun.misc.Unsafe and to brittle "public-but-internal" APIs that the JDK team couldn't remove without breaking users.

Modules solve three problems at once:

// Before modules — nothing stops this import sun.misc.BASE64Encoder; // internal JDK class import com.example.internal.Helper; // internal library class — should be hidden // With modules — these imports FAIL at compile time if the packages aren't exported // You are forced to use only the intended public API You do not have to modularize your own application to benefit from modules. The JDK itself uses JPMS, so even classpath applications get protection against accidentally depending on internal JDK APIs (enforced via command-line flags in modern Java).

A module is defined by a file named module-info.java placed at the root of the module's source tree (the same level as the top-level package). It declares the module's name, its dependencies, and what it exposes. The module name typically follows the reverse-domain convention (matching the main package name).

// src/module-info.java (sits beside the com/ package directory) module com.example.accounts { // Packages this module exposes to ALL other modules exports com.example.accounts.api; exports com.example.accounts.model; // Package exposed only to a specific module (qualified export) exports com.example.accounts.internal.spi to com.example.accounts.impl; // Modules this module depends on (compile-time and runtime) requires java.base; // always implicit, listed for clarity requires java.sql; requires com.example.common; // Compile-time-only dependency (like an annotation processor) requires static com.example.annotations; // Transitive: anyone requiring this module also gets java.logging requires transitive java.logging; }

The module source layout mirrors the module name:

// Typical layout for module com.example.accounts src/ com.example.accounts/ module-info.java com/ example/ accounts/ api/ AccountService.java // exported — visible outside the module model/ Account.java // exported — visible outside the module internal/ AccountRepository.java // NOT exported — hidden from other modules AccountValidator.java // NOT exported

requires declares a compile-time and runtime dependency on another module. exports makes a package's public types visible to other modules. Together they form the core of the module declaration. The key insight: a package that is not exported is completely inaccessible from outside the module — even if every class in it is public.

// Module A: provides a service API + implementation module com.example.payment { // Only the api package is visible to consumers exports com.example.payment.api; // Internal packages — invisible outside this module // com.example.payment.internal (hidden) // com.example.payment.repository (hidden) requires java.base; requires java.sql; } // Module B: consumes Module A module com.example.checkout { // Declare the dependency requires com.example.payment; // Also exposes its own API exports com.example.checkout.api; requires java.base; } // In Module B's code: package com.example.checkout.service; import com.example.payment.api.PaymentProcessor; // OK — exported package // import com.example.payment.internal.StripeClient; // COMPILE ERROR — not exported public class CheckoutService { private final PaymentProcessor processor; // ... }

The requires transitive modifier means that any module which requires yours automatically also gets the transitive dependency — useful when your API returns types from another module.

// If com.example.common exports a type used in com.example.accounts' API: module com.example.accounts { exports com.example.accounts.api; // Without 'transitive', users of com.example.accounts would ALSO have to // declare 'requires com.example.common' to use types from its API. // With 'transitive', it's inherited automatically. requires transitive com.example.common; }

Reflection can bypass normal access controls — frameworks like Spring, Hibernate, and Jackson use it to inspect and manipulate private fields. Modules block this by default: even for exported packages, reflection on private members is forbidden unless the module explicitly opens the package. opens grants deep reflection access (including private members) while keeping the package's types hidden from normal code.

module com.example.persistence { exports com.example.persistence.api; // normal access to public types // Opens the entity package to Hibernate for reflection opens com.example.persistence.entity to org.hibernate.orm.core; // Opens to Jackson for JSON serialization opens com.example.persistence.dto to com.fasterxml.jackson.databind; // open module — opens ALL packages to reflection from any module // Use sparingly; only appropriate for application modules, not libraries // open module com.example.app { ... } requires java.base; requires java.persistence; requires org.hibernate.orm.core; requires com.fasterxml.jackson.databind; }
DirectiveNormal access (public types)Reflective access (private members)
exports pkg✅ All modules❌ Forbidden
exports pkg to M✅ Module M only❌ Forbidden
opens pkg❌ Hidden (runtime only)✅ All modules
opens pkg to M❌ Hidden✅ Module M only
exports + opens✅ All modules✅ All modules
Avoid opens to all modules (opens pkg; without a target). It re-exposes your internals to reflection from any code and undermines the encapsulation you're trying to achieve. Prefer opens pkg to specificFrameworkModule.

The module system has first-class support for the Service Loader pattern — a way to decouple an API from its implementations. A module that consumes a service declares uses ServiceInterface. A module that provides an implementation declares provides ServiceInterface with ImplementationClass. At runtime, ServiceLoader.load(ServiceInterface.class) discovers all registered implementations.

// --- Module 1: API (com.example.crypto.api) --- module com.example.crypto.api { exports com.example.crypto.api; // exports the interface } // The service interface package com.example.crypto.api; public interface HashAlgorithm { String name(); byte[] hash(byte[] data); } // --- Module 2: SHA-256 implementation (com.example.crypto.sha256) --- module com.example.crypto.sha256 { requires com.example.crypto.api; provides com.example.crypto.api.HashAlgorithm with com.example.crypto.sha256.Sha256Algorithm; } package com.example.crypto.sha256; import com.example.crypto.api.HashAlgorithm; import java.security.MessageDigest; public class Sha256Algorithm implements HashAlgorithm { @Override public String name() { return "SHA-256"; } @Override public byte[] hash(byte[] data) { try { return MessageDigest.getInstance("SHA-256").digest(data); } catch (Exception e) { throw new RuntimeException(e); } } } // --- Module 3: Consumer (com.example.app) --- module com.example.app { requires com.example.crypto.api; uses com.example.crypto.api.HashAlgorithm; // declares intent to load services } // Consumer code — discovers all HashAlgorithm implementations at runtime import java.util.ServiceLoader; import com.example.crypto.api.HashAlgorithm; ServiceLoader<HashAlgorithm> loader = ServiceLoader.load(HashAlgorithm.class); for (HashAlgorithm algo : loader) { System.out.println("Found algorithm: " + algo.name()); } // Found algorithm: SHA-256

Not all JARs are modular. JPMS handles non-modular JARs in two ways:

Unnamed module: code on the classpath (not the module path) is placed in a single unnamed module. It can read all named modules, and all named modules that requires nothing special can read its packages. The unnamed module is a compatibility shim — it lets existing classpath applications work unchanged on Java 9+.

Automatic module: a plain (non-modular) JAR placed on the module path becomes an automatic module. Its name is derived from the JAR filename (hyphens become dots, version suffix stripped). It exports all its packages and requires all other modules — a broad "trust everyone" mode that enables incremental modularization.

// Placing a non-modular library on the module path makes it an automatic module. // Example: guava-32.0.0-jre.jar → automatic module name: com.google.guava // Your module can then declare: module com.example.app { requires com.google.guava; // automatic module name from MANIFEST.MF or jar filename exports com.example.app.api; } // To check a JAR's automatic module name before using it: // $ jar --describe-module --file=guava-32.0.0-jre.jar // No module descriptor found. // Deriving module descriptor from JAR manifest and filename. // com.google.guava@32.0.0-jre automatic // requires java.base mandated // contains com.google.common.annotations // ... Library authors should add an Automatic-Module-Name entry to their MANIFEST.MF to declare a stable automatic module name before migrating to full JPMS. This lets consumers depend on a predictable name rather than a JAR-filename-derived one that may change between releases.

Migrating an existing application to modules is an incremental process. The recommended approach is bottom-up: modularize the leaf libraries first, then work upward toward the application code.

  1. Audit dependencies — run jdeps --multi-release 17 --module-path libs/ myapp.jar to see what each JAR depends on
  2. Check for split packages — JPMS forbids the same package appearing in two modules. Identify and merge or rename them.
  3. Start with automatic modules — move third-party JARs to the module path; they become automatic modules
  4. Add module-info.java — start with a minimal descriptor (requires your deps, exports your API packages)
  5. Fix encapsulation violations — the compiler will tell you which internal packages you were accidentally depending on
  6. Add opens as needed — for frameworks (Spring, Hibernate, Jackson) that need reflection access
// Step 1: minimal module-info.java — just enough to compile module com.example.myapp { requires java.base; requires java.sql; requires com.google.guava; // automatic module // Start by exporting everything, tighten later exports com.example.myapp; exports com.example.myapp.service; exports com.example.myapp.model; // Allow Spring to reflect on your beans opens com.example.myapp.service to spring.beans, spring.context; opens com.example.myapp.model to com.fasterxml.jackson.databind; } // Run jdeps to discover what you're missing: // $ jdeps --module-path mods/ --add-modules ALL-MODULE-PATH myapp.jar

Once your application and its dependencies are modular, jlink can create a self-contained, minimal JRE image containing only the JDK modules your application actually uses. A typical desktop application needs only a fraction of the full JDK — this dramatically reduces deployment size (from ~200 MB to tens of MB).

// Discover which JDK modules your app needs: // $ jdeps --print-module-deps --module-path mods/ myapp.jar // java.base,java.sql,java.logging // Create a custom JRE with exactly those modules + your application: // $ jlink \ // --module-path $JAVA_HOME/jmods:mods/ \ // --add-modules com.example.myapp,java.base,java.sql,java.logging \ // --launcher myapp=com.example.myapp/com.example.myapp.Main \ // --output dist/myapp-runtime \ // --compress=2 \ // --strip-debug \ // --no-man-pages // Run the custom image: // $ dist/myapp-runtime/bin/myapp // List modules in the image: // $ dist/myapp-runtime/bin/java --list-modules // com.example.myapp // java.base@21 // java.logging@21 // java.sql@21 jlink images are not universal JREs — they are platform-specific binaries for the OS and architecture you build on. Use a build matrix (e.g., GitHub Actions) to produce images for Linux, macOS, and Windows simultaneously. For container deployments, a minimal JRE image is smaller than even the slim OpenJDK Docker images.