The Java Module System (JPMS), introduced in Java 11, doesn’t just organize code; it fundamentally redefines how Java applications are built, deployed, and executed, moving beyond simple classpath hell to a more robust and reliable dependency graph.

Let’s see JPMS in action with a simple example. Imagine we have two modules: com.example.greet and com.example.app.

com.example.greet will expose a greeting service:

// src/com.example.greet/module-info.java
module com.example.greet {
    exports com.example.greet; // Makes the package available to other modules
}
// src/com.example.greet/com/example/greet/GreetingService.java
package com.example.greet;

public class GreetingService {
    public String greet(String name) {
        return "Hello, " + name + "!";
    }
}

com.example.app will use this service:

// src/com.example.app/module-info.java
module com.example.app {
    requires com.example.greet; // Declares a dependency on com.example.greet
}
// src/com.example.app/com/example/app/Main.java
package com.example.app;

import com.example.greet.GreetingService;

public class Main {
    public static void main(String[] args) {
        GreetingService service = new GreetingService();
        System.out.println(service.greet("JPMS User"));
    }
}

To compile and run this, we’d typically use javac with the --module-path and --add-modules flags, and java with the --module-path and -m flags.

First, compile each module into its own directory (e.g., mods):

mkdir mods
javac --module-source-path src -d mods/com.example.greet src/com.example.greet/module-info.java src/com.example.greet/com/example/greet/GreetingService.java
javac --module-source-path src -d mods/com.example.app src/com.example.app/module-info.java src/com.example.app/com/example/app/Main.java

Then, package them into modular JARs:

jar --create --file mods/com.example.greet.jar --dir mods/com.example.greet
jar --create --file mods/com.example.app.jar --dir mods/com.example.app

Finally, run the application:

java -p mods -m com.example.app/com.example.app.Main

This will output: Hello, JPMS User!

The core problem JPMS solves is dependency hell and "JAR hell". Before JPMS, managing dependencies was a chaotic mess of classpath ordering, version conflicts, and runtime ClassNotFoundException or NoClassDefFoundError issues. JPMS introduces a declarative, explicit way to declare module dependencies and the packages they expose. This means the JVM can verify that all required modules are present and that their exported packages are accessible at compile time, significantly reducing runtime errors.

Internally, JPMS works by transforming the classpath concept into a module graph. Each module explicitly declares what it requires from other modules and what packages it exports to others. The JVM then builds a graph of these modules, resolving dependencies and ensuring encapsulation. If module A requires module B, and module B exports package com.example.util, then module A can import com.example.util.* and use its classes. If module B doesn’t export that package, or if module A tries to require a module that doesn’t exist, the build or runtime will fail with clear error messages, not cryptic exceptions.

The exact levers you control are primarily within the module-info.java file:

  • module <module-name> { ... }: Defines the module.
  • requires <module-name>;: Declares a compile-time and runtime dependency on another module.
  • exports <package-name>;: Makes a package’s public types visible to other modules.
  • opens <package-name>;: Allows reflection access to a package’s types.
  • uses <service-type>;: Declares that this module intends to use a service of a given type.
  • provides <service-type> with <implementation-type>;: Declares that this module provides an implementation for a given service type.

The most surprising thing about JPMS is how it forces a shift from implicit, classpath-based visibility to explicit, module-based encapsulation, fundamentally changing how you think about code organization and distribution. It’s not just about packaging; it’s about defining clear boundaries and contracts between different parts of your application and its dependencies.

When migrating existing applications, a common pitfall is the java.lang.module.FindException: Module ... not found or java.lang.NoClassDefFoundError that appears even when the JAR is on the classpath. This often happens because the JVM, when run with --module-path, no longer treats JARs on the traditional classpath as "modules" unless explicitly told to. To include legacy JARs (that don’t have module-info.java) on the module path, you need to use the --add-modules flag to tell the JVM which "unnamed modules" (legacy JARs) to include. For example, if your application depends on a legacy JAR legacy.jar, you might run your application with java -p mods:legacy.jar --add-modules ALL-MODULE-PATH -m com.example.app/com.example.app.Main.

The next major hurdle after understanding basic module declaration and resolution is mastering the Service Provider Interface (SPI) mechanism within JPMS.

Want structured learning?

Take the full Java course →