Modular Java

Reviewed & published by Brayan K

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.

Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

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.

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.

Module path (-p / --module-path)

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);
        }
    }
}

🎯 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 #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: 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 deps

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)

Pro Tips

🧗 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:

📋 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

🎉 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.

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.

Continue this course

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.

Related lessons