The Java Platform Module System (JPMS)
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
publicclass 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:
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.
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.
There are three flavours of requires:
requires M— a compile-time and runtime dependency on moduleM.requires transitive M— a dependency onMthat is re-exported: any module that requires yours automatically also readsM. Use this when types fromMappear in your public API (method parameters, return types, field types). If you omittransitivewhen it is needed, callers will get a compile error because they cannot seeM's types.requires static M— a compile-time-only dependency;Mneed not be present at runtime. Used for optional integrations and annotation processors.
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.
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:
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:
The package com.example.payments.api might contain an interface:
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:
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.