Build Tools & Modules

The Java Platform Module System (JPMS)

15 min Lesson 7 of 13

The Java Platform Module System (JPMS)

Introduced in Java 9 (Project Jigsaw), the Java Platform Module System (JPMS) fundamentally changed how the JDK itself is structured — and gave application developers a first-class mechanism for expressing strong encapsulation and explicit dependencies at the artifact level. After two decades of relying solely on packages and the classpath, Java finally got a module system that the compiler and JVM enforce.

Why JPMS Exists: The Classpath Problem

Before Java 9 the classpath was a flat, unordered pile of JARs. This caused well-known pain points that JPMS was designed to eliminate:

  • JAR hell — two JARs on the classpath may define classes in the same package; the JVM silently picks one. Errors surface at runtime, not compile time.
  • No reliable encapsulation — any public class anywhere on the classpath is accessible to everything else. There was no way to say "this is an internal implementation class — stay out."
  • No explicit dependency graph — a JAR carries no machine-readable list of what other JARs it needs. Tools and humans have to infer it.
  • Monolithic JDK — every Java application shipped with the entire JDK regardless of what it actually used, making minimal images and IoT deployments impractical.

JPMS addresses all four by replacing the flat classpath with a module graph where every node declares exactly what it needs and exactly what it exposes.

The module-info.java File

A module is a JAR (or directory) that contains a module-info.java at its root. This file is the module descriptor. It is compiled by javac into a module-info.class that the JVM reads at launch. Every directive inside it is checked by the compiler and enforced at runtime — not advisory, not a comment, not a Maven convention.

The minimal descriptor for a module named com.example.payments:

module com.example.payments { }

A module with no directives still exists in the module graph; it just does not export any package and does not declare any dependency beyond java.base, which every module implicitly requires.

Module naming convention: use the reverse-DNS convention you already use for packages — typically match the root package of the module. A module named com.example.payments would normally contain packages like com.example.payments.api, com.example.payments.model, etc.

The requires Directive

requires declares that this module depends on another named module. The compiler and JVM will refuse to start if any required module is absent from the module path.

module com.example.payments { requires java.net.http; // JDK module — the HTTP client API requires com.fasterxml.jackson.databind; // third-party on the module path requires org.slf4j; }

There are three flavours of requires:

  • requires M — a compile-time and runtime dependency on module M.
  • requires transitive M — a dependency on M that is re-exported: any module that requires yours automatically also reads M. Use this when types from M appear in your public API (method parameters, return types, field types). If you omit transitive when it is needed, callers will get a compile error because they cannot see M's types.
  • requires static M — a compile-time-only dependency; M need not be present at runtime. Used for optional integrations and annotation processors.
module com.example.payments.api { // consumers of this module also need java.sql because our types extend it requires transitive java.sql; // annotation processor only needed at compile time requires static lombok; }

The exports Directive

exports is the gatekeeper. Only exported packages are visible to other modules — and "visible" means the compiler and JVM enforce it, not just a naming convention. A package that is not exported is effectively private to the module, no matter how many classes in it are declared public.

module com.example.payments { requires java.net.http; requires com.fasterxml.jackson.databind; requires org.slf4j; // Public API surface — available to all modules exports com.example.payments.api; exports com.example.payments.model; // Internal implementation — NOT exported; invisible to other modules // com.example.payments.internal is intentionally absent here }

If code in another module tries to use a class in com.example.payments.internal, the compiler emits an error. At runtime the JVM throws IllegalAccessException. This is strong encapsulation — finally meaningful.

Qualified exports

Sometimes you want to expose a package to exactly one trusted module (a sibling module, a testing module, a framework) but not to the whole world. Use a qualified export:

module com.example.payments { exports com.example.payments.api; exports com.example.payments.internal to com.example.payments.tests; // test module only }
Design for minimal surface area. Export only the packages that form your intentional public API. If you find yourself exporting an "internal" package to many unrelated modules, that is a design smell — consider moving the shared types into a dedicated API module.

A Realistic Multi-Package Module

Here is a practical example of a payments module that depends on Jackson for JSON serialization and exposes a clean API while hiding its HTTP transport internals:

// src/com.example.payments/module-info.java module com.example.payments { // Runtime deps requires java.net.http; requires com.fasterxml.jackson.databind; requires org.slf4j; // Transitive: our API returns java.time types requires transitive java.base; // implicit, but explicit for clarity // Public API packages exports com.example.payments.api; exports com.example.payments.model; // com.example.payments.http — internal, NOT exported // com.example.payments.cache — internal, NOT exported }

The package com.example.payments.api might contain an interface:

package com.example.payments.api; import com.example.payments.model.Payment; import java.util.concurrent.CompletableFuture; public interface PaymentGateway { CompletableFuture<Payment> submit(Payment payment); CompletableFuture<Payment> status(String paymentId); }

And the HTTP implementation lives in com.example.payments.http — invisible to consumers. They code to the PaymentGateway interface; the implementation detail is truly hidden.

opens and Reflection

Strong encapsulation also blocks reflective access, which breaks frameworks that use reflection (Spring, Hibernate, Jackson). The opens directive relaxes this for reflection only:

module com.example.payments { exports com.example.payments.api; exports com.example.payments.model; // Allow Jackson to reflectively inspect model classes at runtime opens com.example.payments.model to com.fasterxml.jackson.databind; }
Do not open everything. A blanket opens com.example.foo (without a target module) gives all modules deep reflective access — effectively undoing encapsulation. Prefer qualified opens ... to targeting only the framework that needs it.

Placing module-info.java in Your Maven or Gradle Project

With Maven the convention is straightforward: put module-info.java in src/main/java/ (at the root, not inside any package directory). javac picks it up automatically. For multi-module Maven projects each artifact has its own module-info.java.

The JVM compiles and enforces the module graph whether you are on the module path (--module-path) or still on the classpath. JARs that contain a module-info.class are named modules; JARs without one become unnamed modules and can see everything — a compatibility bridge for legacy code.

Summary

JPMS gives Java developers two powerful tools: requires to express the dependency graph explicitly, and exports to define a precise, compiler-enforced API boundary. Together they eliminate classpath ambiguity, enforce encapsulation beyond what packages alone can provide, and enable the JVM to build optimized runtime images. The next lesson applies these concepts by structuring a real project as a set of cooperating named modules.