2.4 JPMS Modules

The Java Platform Module System (JPMS), introduced in Java 9 and stable through Java 25, brings strong encapsulation and explicit boundaries to your applications. Instead of relying on classpath conventions, modules declare what they depend on and what they expose.

Why Modularize?

Without Modules (Classpath):

  • All JAR files dumped on the classpath
  • No explicit dependencies between components
  • Version conflicts between transitive dependencies
  • Internal implementation details are accidentally exposed
  • Difficult to distribute custom runtime images

With Modules (JPMS):

  • Explicit module graph with compile-time checking
  • Strong encapsulation: only exports are visible
  • Reliable configuration: version conflicts are caught early
  • Custom runtime images: include only needed modules
  • Clear architecture: module boundaries document intent

Module Declaration

Every modular JAR contains module-info.java at the root of the source directory:

module com.example.app.core {
    // Declare dependencies
    requires java.base;           // implicit, but can be explicit
    requires java.logging;
    requires com.fasterxml.jackson.databind;

    // Declare what this module exposes
    exports com.example.app.core.api;
    exports com.example.app.core.model;

    // Open packages for reflection (use sparingly)
    opens com.example.app.core.internal to com.fasterxml.jackson.databind;
}

Module Directives

Directive Purpose Example
requires Depends on another module (must exist at compile & run time) requires java.net.http;
requires transitive Depends on module AND re-exports its API requires transitive com.example.api;
exports Makes a package visible to other modules exports com.example.core.api;
opens Allows reflection access to a package (for frameworks) opens com.example.internal to jackson.databind;
uses Service interface this module consumes uses java.spi.ToolProvider;
provides Service implementation this module supplies provides java.spi.ToolProvider with com.example.MyTool;

Building Modular Applications

Project Structure
myapp/
├── pom.xml
├── src/
│   ├── main/
│   │   ├── java/
│   │   │   ├── com/example/app/api/
│   │   │   │   ├── PublicAPI.java
│   │   │   │   └── (exported)
│   │   │   ├── com/example/app/core/
│   │   │   │   ├── CoreService.java
│   │   │   │   └── (internal, not exported)
│   │   │   └── module-info.java
│   │   └── resources/
│   └── test/
│       └── java/
│           ├── com/example/app/test/
│           │   └── CoreServiceTest.java
│           └── module-info.java (test-time only)
Maven POM Configuration
<properties>
    <maven.compiler.source>25</maven.compiler.source>
    <maven.compiler.target>25</maven.compiler.target>
</properties>

<dependencies>
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <version>2.16.0</version>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.11.0</version>
            <configuration>
                <source>25</source>
                <target>25</target>
            </configuration>
        </plugin>
    </plugins>
</build>
Gradle Build Configuration
plugins {
    id 'java'
    id 'org.gradle.java-library' version '8.5'
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(25)
    }
}

dependencies {
    implementation 'com.fasterxml.jackson.core:jackson-databind:2.16.0'
    testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0'
}

tasks.withType(JavaCompile).configureEach {
    options.release = 25
}

Real-World Example: Multi-Module Application

Module A: Data Model (API Module)

myapp-model/src/main/java/module-info.java

module com.example.app.model {
    exports com.example.app.model;
}

myapp-model/src/main/java/com/example/app/model/User.java

package com.example.app.model;

public record User(String id, String name, String email) {}
Module B: Core Service (Implementation)

myapp-core/src/main/java/module-info.java

module com.example.app.core {
    requires com.example.app.model;
    requires java.logging;
    requires com.fasterxml.jackson.databind;

    // Allow Jackson to reflect on model classes
    opens com.example.app.core.persistence to com.fasterxml.jackson.databind;

    exports com.example.app.core;
}

myapp-core/src/main/java/com/example/app/core/UserService.java

package com.example.app.core;

import com.example.app.model.User;
import java.util.ArrayList;
import java.util.List;

public class UserService {
    private final List<User> users = new ArrayList<>();

    public void addUser(User user) {
        users.add(user);
    }

    public List<User> getAllUsers() {
        return new ArrayList<>(users);
    }
}
Module C: CLI (Application Entry Point)

myapp-cli/src/main/java/module-info.java

module com.example.app.cli {
    requires com.example.app.core;
    requires com.example.app.model;
}

myapp-cli/src/main/java/com/example/app/App.java

package com.example.app;

import com.example.app.core.UserService;
import com.example.app.model.User;

public class App {
    public static void main(String[] args) {
        UserService service = new UserService();
        service.addUser(new User("1", "Alice", "alice@example.com"));
        service.addUser(new User("2", "Bob", "bob@example.com"));

        service.getAllUsers().forEach(user ->
            System.out.println(user.name() + " (" + user.email() + ")")
        );
    }
}

Reflection and Framework Integration

Frameworks like Jackson, Spring, and Hibernate use reflection to populate fields and call methods. For these cases, use opens to allow reflection without exposing the package:

module com.example.app.persistence {
    requires com.example.app.model;
    requires com.fasterxml.jackson.databind;
    requires java.persistence;

    // Exports the public service API
    exports com.example.app.persistence;

    // Opens internal package only to Jackson for reflection
    opens com.example.app.persistence.model to com.fasterxml.jackson.databind;

    // Opens internal package to JPA provider for ORM
    opens com.example.app.persistence.entity to org.hibernate.orm.core;
}

Testing Modular Code

Create a separate module-info.java for tests that needs access to internals:

src/test/java/module-info.java

open module com.example.app.core {
    requires com.example.app.model;
    requires java.logging;
    requires com.fasterxml.jackson.databind;
    requires org.junit.jupiter.api;
    requires org.junit.jupiter.engine;
}

Then write tests normally:

package com.example.app.core;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class UserServiceTest {
    @Test
    void testAddUser() {
        UserService service = new UserService();
        service.addUser(new com.example.app.model.User("1", "Alice", "alice@example.com"));
        assertEquals(1, service.getAllUsers().size());
    }
}

Automatic Modules

Legacy libraries without module-info.java can still be used via automatic modules:

module com.example.app {
    requires some.legacy.library;  // automatic module from legacy-library.jar
}

The JAR filename becomes the module name (with - converted to .). However, automatic modules expose all packages, so avoid them in new designs. Instead:

  • Upgrade dependencies to modular versions
  • Wrap legacy libraries in adapter modules

Validation and Debugging

Show Module Resolution
java --show-module-resolution -cp target/classes com.example.app.App

Output displays the complete module graph and any conflicts.

Check Module Dependencies
jdeps --module-path target/classes --add-modules com.example.app.cli \
      com.example.app/target/classes

Shows which modules depend on which and potential issues.

Enable Strict Module Access
javac --strict-module-checks src/main/java

Fails compilation if a module tries to access non-exported packages.

Best Practices

  1. Export Only Public APIs: Don't export internal packages
  2. Use requires transitive Sparingly: Only for APIs that downstream modules need
  3. Version Your Modules: Include version info in documentation
  4. Open Packages Minimally: Only to frameworks that require reflection
  5. Avoid Circular Dependencies: Module A requires B, B requires A → compilation fails
  6. Test With Module Path: Always test with --module-path, not classpath

When to Modularize

Start with modules when:

  • Building libraries that others will use
  • Creating large applications with clear architectural boundaries
  • Distributing custom runtime images
  • Needing strong encapsulation

Keep as classpath for now when:

  • Small single-purpose applications
  • Dependencies don't have JPMS support (use automatic modules as bridge)

Conclusion

JPMS brings order to large Java applications. By explicitly declaring module dependencies and exports, you create self-documenting, maintainable code with clear boundaries. Combined with jlink and jpackage, modular architecture enables efficient distribution and deployment.

Next section: Creating minimal runtime images and native installers with jlink and jpackage.