Modular Java
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 .
Learn Modular Java in our free Java course — a beginner-friendly interactive lesson with worked examples, a practice exercise and a quick reference.
Part of the free Java course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
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).
💡 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 .
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 :
The key idea: public stops meaning "anyone can use it." Now a class is reachable from outside only if its package is exports ed. Everything else is hidden — that's strong encapsulation .
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.
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.
Fill in the three directives. Then check your answers against the // ✅ Expected comment at the bottom.
Wire a new provider into the plugin system from worked example 2 without touching the consumer.
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 .
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.
No starter code this time — just an outline. Build the three modules, then prove a new provider works without touching the app.
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.
Practice quiz
Which directive declares a dependency on another module?
- exports
- opens
- requires
- uses
Answer: requires. requires names another module; without it, even built-in modules like java.sql are invisible to your code.
Which directive makes a package compilable and usable by other modules?
- exports
- opens
- provides
- requires
Answer: exports. exports publishes a package as part of your public API. Only exported packages are visible to other modules.
A framework like Jackson needs deep reflection (setAccessible(true)) into your model package. Which directive grants that?
- exports
- requires transitive
- uses
- opens
Answer: opens. opens grants runtime reflection (including private members) but NOT compile-time access. exports alone is not enough.
What does 'requires transitive M;' do?
- Hides M from your consumers
- Passes the dependency on M to anyone who requires you
- Opens M for reflection
- Registers M as a service
Answer: Passes the dependency on M to anyone who requires you. requires transitive re-exports the dependency: any module that requires you also gets M for free.
Once a module-info.java exists, what makes a class reachable from other modules?
- Its package being exported
- Marking it public
- Placing it in the default package
- Adding a main method
Answer: Its package being exported. public is no longer enough — a class is reachable only if its package is exported. That is strong encapsulation.
Which pair wires a ServiceLoader plugin together?
- exports on both sides
- opens on the provider and requires on the consumer
- provides...with on the provider and uses on the consumer
- requires transitive on both sides
Answer: provides...with on the provider and uses on the consumer. The provider declares 'provides Service with Impl;' and the consumer declares 'uses Service;'. ServiceLoader.load ties them.
Which flag runs code on the module path instead of the classpath?
- -cp
- -p (--module-path)
- -jar
- -classpath
Answer: -p (--module-path). -p (--module-path) reads each JAR as a module and enforces its module-info.java; -cp is the old flat classpath.
What does jlink produce?
- A fat JAR with all dependencies
- A module-info.java file
- A list of split packages
- A self-contained custom runtime image of only the modules you need
Answer: A self-contained custom runtime image of only the modules you need. jlink links your modules plus only the JDK modules they use into a slim runtime that needs no separate JDK.
What is an automatic module?
- A module the JVM generates from your source
- A plain JAR (no module-info.java) placed on the module path
- A module that opens every package automatically
- A module created by jlink
Answer: A plain JAR (no module-info.java) placed on the module path. A plain JAR on the module path becomes an automatic module: it reads every module and exports all its packages, easing migration.
Why does the build fail with a 'split package' error?
- A package is exported twice
- A module has no main class
- The same package name appears in two modules
- An import is missing
Answer: The same package name appears in two modules. JPMS requires a package to belong to exactly one module. Merge the package into one module or rename one side.