Skip to content

Java Platform Module System — Complete Guide

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about Java Platform Module System. We cover key concepts, practical examples, and best practices to help you master this topic.

The Need for Modularity

Before Java 9, the JDK itself was a monolithic beast. Every application had access to the entire JDK, including internal APIs like com.sun.* that were never meant for public use. The classpath suffered from JAR hell where conflicting versions of the same library could coexist silently, often causing runtime errors that were difficult to diagnose. There was no way to express that a library depends on a specific version of another library, and no mechanism to hide internal implementation details from consumers.

The Java Platform Module System (JPMS), introduced in Java 9 as Project Jigsaw, solves these problems. Modules explicitly declare their dependencies and which packages they export. The JVM enforces these declarations at both compile time and runtime, catching missing dependencies and illegal access early. The JDK itself was split into modules, allowing custom runtime images that include only the modules your application needs.

flowchart TB
    subgraph Before[Java 8 and earlier]
        A[Monolithic rt.jar] --> B[All classes visible]
        B --> C[No encapsulation]
        C --> D[JAR hell on classpath]
    end
    subgraph After[Java 9+ with JPMS]
        E[Modular JDK] --> F[Explicit exports]
        F --> G[Strong encapsulation]
        G --> H[jlink custom runtimes]
    end

Module Basics

A module is a named, self-describing collection of code and data. Modules are defined in a file named module-info.java placed at the root of the source tree.

The module-info.java File

// src/com.example.app/module-info.java
module com.example.app {
    requires java.sql;
    requires transitive com.example.common;
    exports com.example.app.api;
    exports com.example.app.internal to com.example.test;
    provides com.example.spi.Service with com.example.app.AppService;
    uses com.example.spi.Plugin;
}

Key directives:

Directive Purpose
requires Declares a dependency on another module
requires transitive Makes the dependency available to consumers
exports Makes a package accessible to other modules
exports ... to Restricts exports to specific modules
opens Allows reflective access to a package
provides ... with Declares a service implementation
uses Declares a dependency on a service interface

Creating a Modular Application

Let's build a simple modular application with two modules: com.example.greeting and com.example.app.

Step 1: Define the Greeting Module

// src/com.example.greeting/module-info.java
module com.example.greeting {
    exports com.example.greeting.api;
}
// src/com.example.greeting/com/example/greeting/api/Greeter.java
package com.example.greeting.api;

public class Greeter {
    public String greet(String name) {
        return "Hello, " + name + "!";
    }
}
// src/com.example.greeting/com/example/greeting/internal/Formatter.java
package com.example.greeting.internal;

// This class is NOT exported - it stays hidden
class Formatter {
    static String format(String message) {
        return "[ " + message + " ]";
    }
}

Step 2: Define the Application Module

// src/com.example.app/module-info.java
module com.example.app {
    requires com.example.greeting;
}
// src/com.example.app/com/example/app/Main.java
package com.example.app;

import com.example.greeting.api.Greeter;

public class Main {
    public static void main(String[] args) {
        Greeter greeter = new Greeter();
        System.out.println(greeter.greet("World"));
        
        // This would NOT compile:
        // import com.example.greeting.internal.Formatter;
    }
}

Step 3: Compile and Run

# Compile the greeting module
javac -d out/greeting \
    src/com.example.greeting/module-info.java \
    src/com.example.greeting/com/example/greeting/api/Greeter.java \
    src/com.example.greeting/com/example/greeting/internal/Formatter.java

# Compile the app module with the greeting module on module path
javac --module-path out/greeting -d out/app \
    src/com.example.app/module-info.java \
    src/com.example.app/com/example/app/Main.java

# Run
java --module-path out/greeting:out/app \
    --module com.example.app/com.example.app.Main

Output:

Hello, World!

Service Loading with Modules

Modules can provide and consume services using the provides and uses directives. This enables the ServiceLoader pattern popularized by SPI (Service Provider Interface).

// Payment SPI module: com.example.payment.spi
module com.example.payment.spi {
    exports com.example.payment.spi;
}

// Payment processor module: com.example.payment.stripe
module com.example.payment.stripe {
    requires com.example.payment.spi;
    provides com.example.payment.spi.PaymentProcessor
        with com.example.payment.stripe.StripeProcessor;
}

// Application module that discovers payment processors
module com.example.app {
    requires com.example.payment.spi;
    uses com.example.payment.spi.PaymentProcessor;
}
// Discovering services at runtime
ServiceLoader<PaymentProcessor> processors =
    ServiceLoader.load(PaymentProcessor.class);
processors.stream().forEach(provider -> {
    PaymentProcessor processor = provider.get();
    System.out.println("Found: " + processor.getClass().getName());
});

One of JPMS's most practical benefits is jlink, which creates a minimal JRE containing only the modules your application needs.

# Create a custom runtime image
jlink --module-path out:$JAVA_HOME/jmods \
    --add-modules com.example.app \
    --output myapp-runtime \
    --launcher myapp=com.example.app/com.example.app.Main

# The resulting runtime is ~30 MB instead of ~300 MB
./myapp-runtime/bin/myapp

Common Mistakes

1. Circular Module Dependencies

Modules cannot have circular dependencies. If module A requires module B, module B cannot require module A.

// This will cause a compile error
module com.example.a {
    requires com.example.b;
}

module com.example.b {
    requires com.example.a; // Error: cyclic dependency
}

Refactor by extracting the shared code into a third module.

2. Splitting Packages Across Modules

A package can belong to only one module. This is called the unique package restriction.

// Error: both modules cannot contain com.example.util
module com.example.core {
    exports com.example.util;
}

module com.example.extra {
    exports com.example.util; // Error: package conflict
}

3. Forgetting opens for Reflection

Libraries like Hibernate, Spring, and Jackson use reflection to access private fields. You must open packages for reflective access.

module com.example.model {
    requires org.hibernate.orm;
    exports com.example.model.entity;
    opens com.example.model.entity to org.hibernate.orm;
}

// Or open the entire module
open module com.example.model {
    // All packages open for reflection
}

4. Mixing Module Path and Classpath

When you put JARs on the classpath, they become part of the unnamed module. Named modules cannot access classes from the unnamed module unless you use --add-reads.

// This fails: named module cannot access unnamed module classes
module com.example.app {
    // Cannot access classes from classpath
}

5. Missing requires transitive

If module A re-exports types from module B, consumers of A also need B. Use requires transitive to avoid forcing consumers to declare their own requires.

6. Overly Broad Exports

Exporting all packages defeats Encapsulation. Export only what consumers need.

Practice Questions

  1. What problem does JPMS solve that the classpath could not address?
  2. What is the difference between exports and opens in module-info.java?
  3. How does jlink reduce the size of Java runtime deployments?
  4. Why can't two modules contain the same package?
  5. What happens if you try to access a non-exported package from another module at compile time? At runtime?

Challenge: Create a three-module application: a logging SPI module, a console logger implementation that provides the SPI, and a main application that discovers and uses loggers via ServiceLoader. Use jlink to produce a minimal runtime.

FAQ

Can I use JPMS with existing libraries that are not modular?

Yes. Non-modular JARs placed on the classpath become part of the unnamed module. You can also use automatic modules by placing JARs on the module path, which creates a module from the JAR's name.

Do I need to modularize all my applications?

No. JPMS is optional. Applications can continue using the classpath. However, modular applications benefit from strong encapsulation, reliable configuration, and jlink deployment.

How do I handle dependencies that use internal JDK APIs?

JPMS prevents access to internal JDK APIs (like sun.misc.Unsafe). Use --add-exports or --add-opens to break encapsulation, but prefer public APIs or replace the dependency.

Can I convert an existing JAR to a module without source changes?

Yes. Add an Automatic-Module-Name entry to the JAR manifest. The JAR becomes an automatic module that exports all packages and can read all other modules.

What is the difference between requires and requires static?

requires static makes the dependency optional at runtime but required at compile time. This is useful for libraries that are only needed during compilation, such as annotation processors.

Mini Project: Modular Calculator Application

Build a calculator application structured as JPMS modules:

  • com.calc.api: Defines the Operation interface with int apply(int a, int b)
  • com.calc.add: Provides addition implementation
  • com.calc.multiply: Provides multiplication implementation
  • com.calc.app: Main module that discovers all operations via ServiceLoader and presents a REPL to the user

Use provides ... with and uses directives. Package the application with jlink.

What's Next

With modularity under your belt, you are ready to dive into the JVM internals in the next lesson, where you will learn about JVM architecture, memory management, and performance tuning.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro