Lesson 39 • Advanced
Modular Java (JPMS)
By the end of this lesson you'll write a module-info.java, control exactly what your code exposes with requires/exports/opens, build a plugin system with provides...with, and ship a slim custom runtime with jlink.
What You'll Learn in This Lesson
- ✓Why the module system was added in Java 9
- ✓Write a module-info.java with requires / exports / opens
- ✓Use provides...with and uses for ServiceLoader plugins
- ✓Tell the module path apart from the old classpath
- ✓Build a tiny custom runtime with jlink and jdeps
- ✓Migrate an existing app using automatic modules
Before You Start
You should know Maven & Gradle (build tools package your modules), Interfaces (ServiceLoader is interface-driven), and ideally Reflection (which is what the opens directive controls).
A Real-World Analogy: A Module Is an Office Building
💡 Analogy: Picture each module as an office building with a security desk. The lobby (exports) is where visitors are allowed — the public API. The back offices are private; no badge gets you in, no matter how "public" the door looks. requires is the list of other buildings you have a pass to enter. opens is handing a trusted contractor (a framework like Jackson) a master key for one floor so they can do maintenance (reflection) — but only at runtime. Before Java 9 there was no security desk at all: every public room in every building was open to everyone, which is exactly how apps ended up depending on someone else's plumbing.
That "no security desk" world was the classpath. JPMS adds the desk, and the rules live in one file per building: module-info.java.
1️⃣ The Module Directives
A module is a named group of packages with an explicit contract: what it needs, and what it lets others use. You write that contract in module-info.java, placed at your source root. Each line is a directive:
| Directive | What It Does | Example |
|---|---|---|
| requires | Depend on another module (else its packages are invisible) | requires java.sql; |
| requires transitive | Pass a dependency on to your consumers | requires transitive java.logging; |
| exports | Make a package compilable/usable by others | exports com.app.api; |
| exports...to | Qualified export — only named modules | exports com.app.x to com.app.tests; |
| opens | Allow runtime reflection (not compile access) | opens com.app.model to jackson; |
| provides...with | Register a service implementation | provides Svc with Impl; |
| uses | Consume a service via ServiceLoader | uses com.app.Plugin; |
The key idea: public stops meaning "anyone can use it." Now a class is reachable from outside only if its package is exportsed. Everything else is hidden — that's strong encapsulation.
// File: src/main/java/module-info.java
//
// A "named module" needs exactly ONE module-info.java in its source root.
// Once this file exists, 'public' is no longer enough to be visible —
// the module decides what leaves the building. That is the whole point.
module com.myapp.core { // the module's unique name
// requires: declare a dependency on ANOTHER module.
// Without this line, even built-in java.sql is invisible to you.
requires java.sql; // now you can import java.sql.*
requires java.net.http; // and java.net.http.*
// requires transitive: anyone who requires YOU also gets this module
// for free (it "leaks" through your public API). Use it when your
// exported types mention types from another module.
requires transitive java.logging; // consumers see java.util.logging too
// exports: make ONE package readable + compilable by other modules.
// Only these two packages are part of your public API.
exports com.myapp.core.api; // visible to every module
exports com.myapp.core.model;
// Qualified export: only THIS named module may read the package.
exports com.myapp.core.internal.testing to com.myapp.tests; // friend access
// opens: allow DEEP REFLECTION at runtime (setAccessible(true)) but
// NOT compile-time access. Frameworks (Jackson, Hibernate, Spring) need
// this to read your private fields. 'exports' alone does not grant it.
opens com.myapp.core.model to com.fasterxml.jackson.databind;
// uses: "I will look up implementations of this service at runtime."
uses com.myapp.spi.PaymentProcessor; // ServiceLoader will find them
// provides ... with: "Here is MY implementation of that service."
provides com.myapp.spi.PaymentProcessor
with com.myapp.core.payment.StripeProcessor;
}
// Anything NOT exported (e.g. com.myapp.core.internal) is completely hidden
// from other modules — even reflection cannot reach it without 'opens'.
// That guarantee is called STRONG ENCAPSULATION.src/main/java/module-info.java in your module's source root. Compile with javac --module-source-path src --module com.myapp.core -d out. There is no console output to show — this file is a contract the compiler enforces.2️⃣ Strong Encapsulation vs the Classpath
On the old classpath, every JAR is dumped into one flat namespace and every public type is reachable. That's why projects accidentally call internal JDK classes like sun.misc.Unsafe and break on upgrade — nothing stops them.
On the module path, the JVM reads each module's module-info.java and enforces it. If a package isn't exported, you simply cannot import it, and a missing requires fails at startup rather than as a confusing runtime crash.
Classpath (-cp)
- Flat — all public types visible
- "JAR hell": duplicates, split packages
- Missing JARs crash mid-run
Module path (-p / --module-path)
- Only exported packages visible
- Reliable configuration, no split packages
- Missing module fails fast at startup
-p instead of -cp, and then its module-info.java is enforced.3️⃣ ServiceLoader — a Plugin System with provides / uses
A service is an interface; a provider is a class that implements it. ServiceLoader finds every provider on the module path at runtime, so your app can be extended by dropping in a new JAR — no code changes, no if/switch.
💡 Analogy: Think of a wall socket standard. The socket (interface) defines the shape. Any appliance (provider module) that fits plugs in. Your app just asks "what's plugged in?" and uses whatever it finds.
The consumer declares uses Service, each provider declares provides Service with Impl, and ServiceLoader.load(Service.class) ties them together.
// ===============================================================
// 1) The SPI module — defines the contract everyone agrees on.
// File: payment-spi/src/main/java/module-info.java
// ===============================================================
module com.myapp.spi {
exports com.myapp.spi; // the interface must be public API
}
// File: payment-spi/src/main/java/com/myapp/spi/PaymentProcessor.java
package com.myapp.spi;
public interface PaymentProcessor {
String name();
boolean process(double amount);
}
// ===============================================================
// 2) A provider module — registers ITS implementation via 'provides'.
// File: payment-stripe/src/main/java/module-info.java
// ===============================================================
module com.myapp.stripe {
requires com.myapp.spi;
provides com.myapp.spi.PaymentProcessor
with com.myapp.stripe.StripeProcessor; // wires impl to the service
}
// File: payment-stripe/src/main/java/com/myapp/stripe/StripeProcessor.java
package com.myapp.stripe;
import com.myapp.spi.PaymentProcessor;
public class StripeProcessor implements PaymentProcessor {
@Override public String name() { return "Stripe"; }
@Override public boolean process(double amount) {
System.out.println("Charging $" + amount + " via Stripe"); // pretend gateway
return true;
}
}
// ===============================================================
// 3) The consumer module — declares 'uses' and asks ServiceLoader.
// File: app/src/main/java/module-info.java
// ===============================================================
module com.myapp.app {
requires com.myapp.spi;
uses com.myapp.spi.PaymentProcessor; // promise to discover providers
}
// File: app/src/main/java/com/myapp/app/Main.java
package com.myapp.app;
import com.myapp.spi.PaymentProcessor;
import java.util.ServiceLoader;
public class Main {
public static void main(String[] args) {
// ServiceLoader scans the module path for every 'provides' of this type.
ServiceLoader<PaymentProcessor> loader = ServiceLoader.load(PaymentProcessor.class);
for (PaymentProcessor p : loader) {
System.out.println("Discovered provider: " + p.name());
p.process(100.00);
}
}
}Discovered provider: Stripe
Charging $100.0 via StripeServiceLoader.load(...) — add another provider JAR to the module path and it appears with zero code changes.🎯 Your Turn #1 — write a module-info.java
Fill in the three directives. Then check your answers against the // ✅ Expected comment at the bottom.
// 🎯 YOUR TURN #1 — finish this module-info.java
//
// You are building a "com.shop.orders" module. It must:
// * use the built-in java.sql module (for the database)
// * expose ONLY its com.shop.orders.api package to other modules
// * let Jackson reflect over com.shop.orders.model to serialize JSON
//
// File: src/main/java/module-info.java
module com.shop.orders {
// 1) Depend on the built-in SQL module
___ java.sql; // 👉 which directive declares a dependency?
// 2) Publish the public API package (and ONLY that one)
___ com.shop.orders.api; // 👉 which directive makes a package visible?
// 3) Allow runtime reflection into the model package for Jackson
___ com.shop.orders.model to com.fasterxml.jackson.databind;
// 👆 which directive grants reflection without compile-time access?
}
// ✅ Expected: line 1 = requires, line 2 = exports, line 3 = opens.
// com.shop.orders.internal stays hidden because it is never exported.___ with the right directive. This is a contract file, so success means it compiles — there's no console output.🎯 Your Turn #2 — register a service provider
Wire a new provider into the plugin system from worked example 2 without touching the consumer.
// 🎯 YOUR TURN #2 — register a second payment provider
//
// You wrote a PayPalProcessor that implements com.myapp.spi.PaymentProcessor.
// Make ServiceLoader discover it WITHOUT touching the consumer's code.
//
// File: payment-paypal/src/main/java/module-info.java
module com.myapp.paypal {
// 1) You need the interface, so depend on the SPI module
requires ___; // 👉 the module that exports the interface
// 2) Announce your implementation to ServiceLoader
provides com.myapp.spi.PaymentProcessor
___ com.myapp.paypal.PayPalProcessor; // 👉 keyword that links service to impl
}
// ✅ Expected: requires com.myapp.spi; and provides ... WITH ...
// Drop this module on the path and Main prints BOTH:
// Discovered provider: Stripe
// Discovered provider: PayPalMain now discovers Stripe and PayPal.4️⃣ Running on the Module Path & Shipping with jlink
You run modular code by naming the module and main class: java -p out -m com.myapp.app/com.myapp.app.Main. The -p flag is the module path; -m picks the entry point.
jlink goes further: it builds a custom runtime image containing only the JDK modules your app actually uses, plus your own modules and a launcher script. The result runs on a machine with no JDK installed and is a fraction of the size — ideal for containers and installers. Use jdeps first to discover exactly which modules you depend on.
Below is a real shell session: compile on the module path, run, then trim it down with jdeps + jlink.
# --- Compile and run on the MODULE PATH (not the classpath) ---
# Compile all modules at once. --module-source-path teaches javac the layout.
$ javac --module-source-path src --module com.myapp.app -d out
# Run by naming module/MainClass. -p (= --module-path) replaces -cp.
$ java -p out -m com.myapp.app/com.myapp.app.Main
Discovered provider: Stripe
Charging $100.0 via Stripe
# --module classpath vs --module-path, side by side:
# java -cp app.jar:lib.jar com.myapp.app.Main <-- classpath: flat, no encapsulation
# java -p out -m com.myapp.app/...Main <-- module path: enforced boundaries
# --- jlink: build a CUSTOM RUNTIME with only the modules you use ---
# 1. Ask jdeps exactly which modules app.jar needs — no more, no less.
$ jdeps --print-module-deps --ignore-missing-deps app.jar
java.base,java.logging,java.net.http,java.sql
# 2. jlink stitches those modules into a self-contained runtime image.
$ jlink \
--module-path "$JAVA_HOME/jmods:out" \
--add-modules com.myapp.app \
--launcher myapp=com.myapp.app/com.myapp.app.Main \
--strip-debug --no-header-files --no-man-pages \
--compress=zip-9 \
--output dist/myapp-runtime
# 3. Run it on a machine with NO JDK installed — the runtime is bundled.
$ dist/myapp-runtime/bin/myapp
Discovered provider: Stripe
Charging $100.0 via Stripe
# 4. Typical size win (why this matters for Docker images):
# Full JDK 21 ~ 320 MB
# jlink runtime ~ 45 MB <-- ships ONLY your modules + their depsjavac, java, jdeps and jlink all ship with the JDK.5️⃣ Migration: Automatic Modules
You don't modularize everything in one go. The migration path is the automatic module: drop a plain JAR (no module-info.java) onto the module path and JPMS treats it as a module that exports all its packages and can read every other module.
Its module name comes from the Automatic-Module-Name manifest entry if present, otherwise from the JAR filename. So you can write requires gson; today even though Gson isn't modular yet, modularize your own code first, and convert dependencies later as they publish real module descriptors.
Automatic-Module-Name, and pin the name yourself before depending on it widely.Common Errors (and the Fix)
- ❌ Split package:
module reads package com.app.util from both A and B. A package may live in exactly one module. Fix: merge the package into one module, or rename one half. - ❌ Forgot to export:
package com.app.api is declared in module M, which does not export it. The class is public but its package isn't exported. Fix: addexports com.app.api;tomodule-info.java. - ❌ Reflection blocked:
InaccessibleObjectException: Unable to make field accessible: module M does not "opens com.app.model". Jackson/Hibernate need deep reflection. Fix: addopens com.app.model to com.fasterxml.jackson.databind;—exportsis not enough. - ❌ Missing requires:
package java.sql is not visible (not in module graph). The dependency exists but you didn't declare it. Fix: addrequires java.sql;. - ❌ Automatic module from JAR name:
automatic module cannot be named, filename does not form a valid name. A version-suffixed JAR (e.g.my-lib-1.2.jar) yields a bad auto name. Fix: rely on the library'sAutomatic-Module-Name, or rename the JAR.
Pro Tips
- 💡 Run
jdeps --print-module-deps app.jarbeforejlink— it tells you the exact--add-moduleslist. - 💡 Use
requires transitiveonly when your exported API mentions another module's types; otherwise keep dependencies private. - 💡 Prefer qualified exports (
exports ... to) over opening packages widely — share less, encapsulate more. - 💡 If you're sprinkling
--add-opensflags everywhere at launch, your modularization is incomplete — fix it inmodule-info.javainstead.
🧗 Mini-Challenge — build a greeter plugin system
No starter code this time — just an outline. Build the three modules, then prove a new provider works without touching the app.
// 🎯 MINI-CHALLENGE: a "greeter" plugin system
//
// Build THREE tiny modules so a new greeting can be added by dropping a JAR:
//
// 1. com.greet.spi
// - exports com.greet.spi
// - contains interface Greeter { String greet(String name); }
//
// 2. com.greet.formal (a provider)
// - requires com.greet.spi
// - provides Greeter with a class that returns "Good day, <name>."
//
// 3. com.greet.app (the consumer)
// - requires com.greet.spi
// - uses com.greet.spi.Greeter
// - main(): ServiceLoader.load(Greeter.class), call greet("Sam") on each
//
// Then ADD com.greet.casual (returns "Hey <name>!") with NO change to the app.
//
// ✅ Expected output once BOTH providers are on the module path:
// Good day, Sam.
// Hey Sam!
//
// Write the three module-info.java files and the classes here:module-info.java files and classes, compile on the module path, and run the consumer. Adding com.greet.casual later must change the output with zero edits to the app module.📋 Quick Reference
| Task | Syntax | Purpose |
|---|---|---|
| Depend on a module | requires java.sql; | Make its exported packages visible |
| Re-export a dependency | requires transitive M; | Consumers get M for free |
| Publish a package | exports com.app.api; | Public API |
| Friend access | exports com.app.x to M; | Qualified export |
| Allow reflection | opens com.app.model to J; | Runtime deep reflection |
| Register a service | provides Svc with Impl; | ServiceLoader provider |
| Consume a service | uses com.app.Plugin; | ServiceLoader consumer |
| Run on module path | java -p out -m M/Main | Module path + entry point |
| Find dependencies | jdeps --print-module-deps | List required modules |
| Custom runtime | jlink --add-modules ... | Slim self-contained image |
❓ Frequently Asked Questions
Do I have to add a module-info.java to use Java 9+?
No. JPMS is opt-in. Code with no module-info.java runs in the 'unnamed module' on the classpath exactly as before, so existing apps keep working. You only get strong encapsulation, jlink and ServiceLoader-on-the-module-path benefits once you add module-info.java files and switch to the module path.
What is the difference between the classpath and the module path?
The classpath is a flat list of JARs where every public type is visible to everything — the source of 'JAR hell' and accidental dependencies on internal APIs. The module path reads each JAR as a module and enforces its module-info.java: you can only use packages another module explicitly exports, and missing dependencies fail fast at startup instead of with a NoClassDefFoundError later.
What is an automatic module?
When you put a plain JAR (one with no module-info.java) on the module path, JPMS turns it into an 'automatic module'. It gets a name derived from the JAR filename (or its Automatic-Module-Name manifest entry), it can read every other module, and it exports all of its packages. Automatic modules are the bridge that lets you migrate incrementally instead of modularizing every dependency at once.
When do I need 'opens' instead of 'exports'?
Use 'exports' to let other modules compile against and call your public types. Use 'opens' when a framework needs deep reflection — setAccessible(true) to read private fields — such as Jackson/Gson for JSON, Hibernate/JPA for entities, or dependency-injection containers. 'exports' does NOT grant reflective access to non-public members; 'opens' does, but only at runtime, not compile time.
What does jlink actually produce?
jlink links a chosen set of modules into a self-contained custom runtime image — your modules plus only the JDK modules they need, with a bin/ launcher. The target machine needs no separate JDK or JRE. A typical image is around 45 MB versus a ~320 MB full JDK, which is why jlink is popular for slim Docker images and desktop installers.
Why does my build fail with 'package is empty or does not exist' or a split-package error?
A split package means two modules contain the same package name; JPMS forbids that because a package must belong to exactly one module. Merge the package into one module or rename one side. The 'empty package' error usually means you exported a package that has no public types in this module — remove the stale exports line or move the types in.
🎉 Lesson Complete!
You can now write a module-info.java, enforce strong encapsulation with exports/opens, build a ServiceLoader plugin system with provides...with, run on the module path, and ship a slim jlink runtime — migrating gradually via automatic modules.
Next up: Deployment — packaging, Docker images, and shipping Java apps to production.
Sign up for free to track which lessons you've completed and get learning reminders.