Skip to main content

    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:

    DirectiveWhat It DoesExample
    requiresDepend on another module (else its packages are invisible)requires java.sql;
    requires transitivePass a dependency on to your consumersrequires transitive java.logging;
    exportsMake a package compilable/usable by othersexports com.app.api;
    exports...toQualified export — only named modulesexports com.app.x to com.app.tests;
    opensAllow runtime reflection (not compile access)opens com.app.model to jackson;
    provides...withRegister a service implementationprovides Svc with Impl;
    usesConsume a service via ServiceLoaderuses 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.

    Worked example: a fully-commented module-info.java
    // 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.
    Place this at 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

    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.

    Worked example: a ServiceLoader plugin across three modules
    // ===============================================================
    // 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);
            }
        }
    }
    Output
    Discovered provider: Stripe
    Charging $100.0 via Stripe
    Three small modules: SPI, provider, consumer. Build them with Maven/Gradle multi-module support. The consumer discovers providers at runtime via ServiceLoader.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 — requires / exports / opens
    // 🎯 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.
    Replace each ___ 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 — provides ... with
    // 🎯 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: PayPal
    Fill in the two blanks. With this provider on the module path, the unchanged Main 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.

    Worked example: module path + jlink custom runtime (shell session)
    # --- 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 deps
    Run these in your own terminal with JDK 21+ installed. javac, 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.

    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: add exports com.app.api; to module-info.java.
    • Reflection blocked: InaccessibleObjectException: Unable to make field accessible: module M does not "opens com.app.model". Jackson/Hibernate need deep reflection. Fix: add opens com.app.model to com.fasterxml.jackson.databind;exports is not enough.
    • Missing requires: package java.sql is not visible (not in module graph). The dependency exists but you didn't declare it. Fix: add requires 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's Automatic-Module-Name, or rename the JAR.

    Pro Tips

    • 💡 Run jdeps --print-module-deps app.jar before jlink — it tells you the exact --add-modules list.
    • 💡 Use requires transitive only 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-opens flags everywhere at launch, your modularization is incomplete — fix it in module-info.java instead.

    🧗 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 — three modules + ServiceLoader
    // 🎯 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:
    Write the three 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

    TaskSyntaxPurpose
    Depend on a modulerequires java.sql;Make its exported packages visible
    Re-export a dependencyrequires transitive M;Consumers get M for free
    Publish a packageexports com.app.api;Public API
    Friend accessexports com.app.x to M;Qualified export
    Allow reflectionopens com.app.model to J;Runtime deep reflection
    Register a serviceprovides Svc with Impl;ServiceLoader provider
    Consume a serviceuses com.app.Plugin;ServiceLoader consumer
    Run on module pathjava -p out -m M/MainModule path + entry point
    Find dependenciesjdeps --print-module-depsList required modules
    Custom runtimejlink --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.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service

    Install LearnCodingFast

    Learn faster with the app on your home screen.