Java Modules (JPMS) – Java Platform Module System

The Java Platform Module System (JPMS), introduced in Java 9, provides stronger encapsulation, reliable configuration, and improved performance through modularity.

1. Module Basics and Structure

Basic Module Structure

my-application/
├── src/
│   ├── com.example.main/
│   │   ├── module-info.java
│   │   └── com/example/main/
│   │       └── Main.java
│   ├── com.example.utils/
│   │   ├── module-info.java
│   │   └── com/example/utils/
│   │       ├── StringUtils.java
│   │       └── MathUtils.java
│   └── com.example.data/
│       ├── module-info.java
│       └── com/example/data/
│           ├── DatabaseService.java
│           └── User.java
└── out/ (compiled modules)

Module Declaration Syntax

// module-info.java for main module
module com.example.main {
// Dependencies
requires com.example.utils;
requires com.example.data;
requires java.sql;
// Exports packages to other modules
exports com.example.main;
// Opens packages for reflection
opens com.example.main.internal;
// Provides services
provides com.example.main.Service
with com.example.main.ServiceImpl;
// Uses services
uses com.example.utils.UtilityService;
}

2. Creating and Using Modules

Module 1: Utility Module

// src/com.example.utils/module-info.java
module com.example.utils {
exports com.example.utils;
exports com.example.utils.math;
// Optional dependency
requires static java.logging;
}
// src/com.example.utils/com/example/utils/StringUtils.java
package com.example.utils;
public class StringUtils {
public static boolean isNullOrEmpty(String str) {
return str == null || str.trim().isEmpty();
}
public static String capitalize(String str) {
if (isNullOrEmpty(str)) return str;
return str.substring(0, 1).toUpperCase() + str.substring(1).toLowerCase();
}
}
// src/com.example.utils/com/example/utils/math/MathUtils.java
package com.example.utils.math;
public class MathUtils {
public static int add(int a, int b) {
return a + b;
}
public static double calculateCircleArea(double radius) {
return Math.PI * radius * radius;
}
}

Module 2: Data Module

// src/com.example.data/module-info.java
module com.example.data {
exports com.example.data;
requires transitive java.sql; // Transitive dependency
// Export to specific modules only
exports com.example.data.internal to com.example.main;
}
// src/com.example.data/com/example/data/User.java
package com.example.data;
public class User {
private final String id;
private final String name;
private final String email;
public User(String id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
// Getters
public String getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
@Override
public String toString() {
return String.format("User{id=%s, name=%s, email=%s}", id, name, email);
}
}
// src/com.example.data/com/example/data/DatabaseService.java
package com.example.data;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
public class DatabaseService {
private Connection connection;
public DatabaseService(String url, String user, String password) throws SQLException {
this.connection = DriverManager.getConnection(url, user, password);
}
public List<User> getUsers() throws SQLException {
List<User> users = new ArrayList<>();
String sql = "SELECT id, name, email FROM users";
try (Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(sql)) {
while (rs.next()) {
users.add(new User(
rs.getString("id"),
rs.getString("name"),
rs.getString("email")
));
}
}
return users;
}
public void close() throws SQLException {
if (connection != null && !connection.isClosed()) {
connection.close();
}
}
}
// Internal package - only accessible to specific modules
package com.example.data.internal;
public class InternalDataProcessor {
public static String processData(String data) {
return "Processed: " + data;
}
}

Module 3: Main Application Module

// src/com.example.main/module-info.java
module com.example.main {
requires com.example.utils;
requires com.example.data;
requires java.sql;
exports com.example.main;
// Open for reflection (e.g., for frameworks)
opens com.example.main to java.base;
// Service provider mechanism
uses com.example.utils.UtilityService;
provides com.example.utils.UtilityService 
with com.example.main.CustomUtilityService;
}
// src/com.example.main/com/example/main/Main.java
package com.example.main;
import com.example.utils.StringUtils;
import com.example.utils.math.MathUtils;
import com.example.data.User;
import com.example.data.DatabaseService;
import com.example.data.internal.InternalDataProcessor; // Allowed due to 'exports ... to'
import java.sql.SQLException;
import java.util.List;
import java.util.ServiceLoader;
public class Main {
public static void main(String[] args) {
System.out.println("=== Module System Demo ===");
// Using utility modules
String name = "john doe";
System.out.println("Capitalized: " + StringUtils.capitalize(name));
System.out.println("Area of circle: " + MathUtils.calculateCircleArea(5.0));
// Using data module
try {
DatabaseService dbService = new DatabaseService(
"jdbc:h2:mem:test", "sa", "");
List<User> users = dbService.getUsers();
users.forEach(System.out::println);
dbService.close();
} catch (SQLException e) {
System.err.println("Database error: " + e.getMessage());
}
// Using internal package (allowed due to qualified exports)
String processed = InternalDataProcessor.processData("test");
System.out.println("Processed data: " + processed);
// Service loader example
ServiceLoader<UtilityService> services = ServiceLoader.load(UtilityService.class);
services.stream()
.map(ServiceLoader.Provider::get)
.forEach(service -> System.out.println("Service: " + service.getName()));
}
}
// Service implementation
package com.example.main;
import com.example.utils.UtilityService;
public class CustomUtilityService implements UtilityService {
@Override
public String getName() {
return "Custom Utility Service";
}
@Override
public String process(String input) {
return "Custom processing: " + input.toUpperCase();
}
}

Service Interface (in utils module)

// Add to com.example.utils module
package com.example.utils;
public interface UtilityService {
String getName();
String process(String input);
}

3. Compiling and Running Modules

Manual Compilation and Execution

# Create output directory
mkdir -p out
# Compile utils module
javac -d out/com.example.utils \
src/com.example.utils/module-info.java \
src/com.example.utils/com/example/utils/StringUtils.java \
src/com.example.utils/com/example/utils/math/MathUtils.java \
src/com.example.utils/com/example/utils/UtilityService.java
# Compile data module
javac -d out/com.example.data \
--module-path out \
src/com.example.data/module-info.java \
src/com.example.data/com/example/data/User.java \
src/com.example.data/com/example/data/DatabaseService.java \
src/com.example.data/com/example/data/internal/InternalDataProcessor.java
# Compile main module
javac -d out/com.example.main \
--module-path out \
src/com.example.main/module-info.java \
src/com.example.main/com/example/main/Main.java \
src/com.example.main/com/example/main/CustomUtilityService.java
# Run the application
java --module-path out \
--module com.example.main/com.example.main.Main

Using Build Tools (Maven)

<!-- pom.xml for utils module -->
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>utils</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<release>17</release>
</configuration>
</plugin>
</plugins>
</build>
</project>

4. Advanced Module Features

Module Layers and Custom Loaders

// Advanced module layer example
package com.example.plugin;
import java.lang.module.*;
import java.nio.file.*;
import java.util.*;
public class PluginSystem {
private final ModuleLayer pluginLayer;
public PluginSystem(Path pluginsDir) {
// Find all plugin modules
List<Path> pluginPaths = findPluginJars(pluginsDir);
// Create configuration for plugin layer
ModuleFinder pluginFinder = ModuleFinder.of(pluginPaths.toArray(new Path[0]));
ModuleLayer parentLayer = ModuleLayer.boot();
Configuration parentConfig = parentLayer.configuration();
Configuration pluginConfig = parentConfig.resolveAndBind(
pluginFinder, ModuleFinder.of(), Set.of("com.example.plugin.*"));
// Create new module layer
this.pluginLayer = parentLayer.defineModulesWithOneLoader(
pluginConfig, ClassLoader.getSystemClassLoader());
}
private List<Path> findPluginJars(Path pluginsDir) {
try {
List<Path> jars = new ArrayList<>();
Files.list(pluginsDir)
.filter(path -> path.toString().endsWith(".jar"))
.forEach(jars::add);
return jars;
} catch (Exception e) {
throw new RuntimeException("Failed to find plugins", e);
}
}
public <T> List<T> loadPlugins(Class<T> pluginInterface) {
List<T> plugins = new ArrayList<>();
for (Module module : pluginLayer.modules()) {
String moduleName = module.getName();
// Use ServiceLoader to find implementations
ServiceLoader<T> loader = ServiceLoader.load(module, pluginInterface);
loader.stream()
.map(ServiceLoader.Provider::get)
.forEach(plugins::add);
}
return plugins;
}
}

Dynamic Modules and Reflection

// Working with modules at runtime
package com.example.runtime;
import java.lang.module.*;
import java.lang.reflect.*;
import java.util.*;
public class ModuleReflection {
public static void examineModule(Module module) {
System.out.println("=== Module: " + module.getName() + " ===");
// Module descriptor
Optional<ModuleDescriptor> descriptor = module.getDescriptor();
descriptor.ifPresent(desc -> {
System.out.println("Packages: " + desc.packages());
System.out.println("Requires: " + desc.requires());
System.out.println("Exports: " + desc.exports());
System.out.println("Uses: " + desc.uses());
System.out.println("Provides: " + desc.provides());
});
// Check if package is open
Set<String> packages = module.getPackages();
for (String pkg : packages) {
boolean isOpen = module.isOpen(pkg);
System.out.println("Package " + pkg + " open: " + isOpen);
}
}
public static void addExports(Module source, String pkg, Module target) {
try {
Method addExports = Module.class.getDeclaredMethod(
"addExports", String.class, Module.class);
addExports.setAccessible(true);
addExports.invoke(source, pkg, target);
System.out.println("Added exports for " + pkg);
} catch (Exception e) {
throw new RuntimeException("Failed to add exports", e);
}
}
public static void addOpens(Module source, String pkg, Module target) {
try {
Method addOpens = Module.class.getDeclaredMethod(
"addOpens", String.class, Module.class);
addOpens.setAccessible(true);
addOpens.invoke(source, pkg, target);
System.out.println("Added opens for " + pkg);
} catch (Exception e) {
throw new RuntimeException("Failed to add opens", e);
}
}
public static void addReads(Module source, Module target) {
try {
Method addReads = Module.class.getDeclaredMethod("addReads", Module.class);
addReads.setAccessible(true);
addReads.invoke(source, target);
System.out.println("Added reads from " + source.getName() + " to " + target.getName());
} catch (Exception e) {
throw new RuntimeException("Failed to add reads", e);
}
}
}

Automatic Modules and Migration

// Working with automatic modules (non-modular JARs)
module com.example.migration {
// Automatic module name derived from JAR filename
requires legacy.lib; // legacy-lib.jar in module path
// Or explicit automatic module
requires lib.utils; // lib.utils-1.0.0.jar
exports com.example.migration;
}
// Migration strategy example
package com.example.migration;
/**
* Migration steps for legacy applications:
* 1. Start by placing all JARs on module path as automatic modules
* 2. Create module-info.java for your own code first
* 3. Gradually modularize dependencies
* 4. Use jdeps tool to analyze dependencies
* 5. Use --add-opens and --add-exports for reflection access
*/
public class MigrationGuide {
public static void migrationCommands() {
// Analyze dependencies
// jdeps --generate-module-info . legacy-lib.jar
// Run with automatic modules
// java --module-path out:libs --module com.example.migration/com.example.migration.Main
// Add opens for reflection
// java --add-opens com.example.migration/com.example.migration.internal=org.springframework.core
}
}

5. Module Patterns and Best Practices

Factory Pattern with Modules

// Service interface
module com.example.service.api {
exports com.example.service.api;
}
// com.example.service.api/com/example/service/api/Service.java
package com.example.service.api;
public interface Service {
String getName();
void execute();
}
// Factory in separate module
module com.example.service.factory {
requires com.example.service.api;
requires transitive com.example.service.provider;
exports com.example.service.factory;
uses com.example.service.api.Service;
}
// com.example.service.factory/com/example/service/factory/ServiceFactory.java
package com.example.service.factory;
import com.example.service.api.Service;
import java.util.*;
public class ServiceFactory {
private final Map<String, Service> services = new HashMap<>();
public ServiceFactory() {
ServiceLoader<Service> loader = ServiceLoader.load(Service.class);
for (Service service : loader) {
services.put(service.getName(), service);
}
}
public Service getService(String name) {
return services.get(name);
}
public Set<String> getAvailableServices() {
return services.keySet();
}
}
// Service implementations in provider modules
module com.example.service.provider.a {
requires com.example.service.api;
provides com.example.service.api.Service
with com.example.service.provider.a.ServiceA;
}
// com.example.service.provider.a/com/example/service/provider/a/ServiceA.java
package com.example.service.provider.a;
import com.example.service.api.Service;
public class ServiceA implements Service {
@Override
public String getName() {
return "ServiceA";
}
@Override
public void execute() {
System.out.println("Executing Service A");
}
}

Configuration Pattern

// Configuration module
module com.example.config {
exports com.example.config;
// Optional dependencies for different configurations
requires static com.example.database;
requires static com.example.messaging;
}
// com.example.config/com/example/config/ApplicationConfig.java
package com.example.config;
import java.util.*;
public class ApplicationConfig {
private final Properties properties = new Properties();
public ApplicationConfig() {
// Load configuration
loadDefaultConfig();
}
private void loadDefaultConfig() {
properties.setProperty("app.name", "Modular Application");
properties.setProperty("app.version", "1.0.0");
// Conditional configuration based on module availability
if (isModulePresent("com.example.database")) {
properties.setProperty("db.enabled", "true");
properties.setProperty("db.url", "jdbc:h2:mem:test");
} else {
properties.setProperty("db.enabled", "false");
}
if (isModulePresent("com.example.messaging")) {
properties.setProperty("messaging.enabled", "true");
} else {
properties.setProperty("messaging.enabled", "false");
}
}
private boolean isModulePresent(String moduleName) {
try {
ModuleLayer.boot().findModule(moduleName).isPresent();
return true;
} catch (Exception e) {
return false;
}
}
public String getProperty(String key) {
return properties.getProperty(key);
}
public String getProperty(String key, String defaultValue) {
return properties.getProperty(key, defaultValue);
}
}

6. Testing with Modules

Test Module Structure

// Test module (for unit tests)
module com.example.main.test {
requires com.example.main;
requires com.example.utils;
requires org.junit.jupiter.api;
requires org.mockito;
// Open for test frameworks
opens com.example.main.test to org.junit.platform.commons;
// Export to test runtime
exports com.example.main.test;
}
// Test class
package com.example.main.test;
import com.example.main.Main;
import com.example.utils.StringUtils;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class MainTest {
@Test
public void testStringUtilsIntegration() {
assertTrue(StringUtils.isNullOrEmpty(null));
assertTrue(StringUtils.isNullOrEmpty(""));
assertFalse(StringUtils.isNullOrEmpty("test"));
}
@Test
public void testCapitalization() {
assertEquals("John", StringUtils.capitalize("john"));
assertEquals("Doe", StringUtils.capitalize("DOE"));
}
}

Integration Testing

// Integration test module
module com.example.integration.test {
requires com.example.main;
requires com.example.utils;
requires com.example.data;
requires org.testcontainers;
requires java.sql;
opens com.example.integration.test to org.junit.platform.commons;
exports com.example.integration.test;
}
// Integration test
package com.example.integration.test;
import com.example.data.DatabaseService;
import com.example.data.User;
import org.junit.jupiter.api.*;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.SQLException;
import java.util.List;
@Testcontainers
public class DatabaseIntegrationTest {
@Container
public static GenericContainer<?> database = 
new GenericContainer<>("postgres:13")
.withExposedPorts(5432)
.withEnv("POSTGRES_DB", "testdb")
.withEnv("POSTGRES_USER", "testuser")
.withEnv("POSTGRES_PASSWORD", "testpass");
private DatabaseService dbService;
@BeforeEach
public void setUp() throws SQLException {
String jdbcUrl = String.format(
"jdbc:postgresql://%s:%d/testdb",
database.getHost(),
database.getFirstMappedPort()
);
dbService = new DatabaseService(jdbcUrl, "testuser", "testpass");
}
@Test
public void testDatabaseConnection() throws SQLException {
List<User> users = dbService.getUsers();
assertNotNull(users);
}
@AfterEach
public void tearDown() throws SQLException {
if (dbService != null) {
dbService.close();
}
}
}

7. Build and Deployment

Module JAR Creation

# Create modular JARs
jar --create \
--file=lib/[email protected] \
--module-version=1.0.0 \
-C out/com.example.utils .
jar --create \
--file=lib/[email protected] \
--module-version=1.0.0 \
-C out/com.example.data .
jar --create \
--file=lib/[email protected] \
--module-version=1.0.0 \
--main-class=com.example.main.Main \
-C out/com.example.main .
# Create JLink runtime
jlink --module-path lib:$JAVA_HOME/jmods \
--add-modules com.example.main \
--output myapp-runtime \
--strip-debug \
--compress=2 \
--no-header-files \
--no-man-pages

Docker Deployment

# Dockerfile for modular application
FROM eclipse-temurin:17-jre-alpine
# Create application directory
WORKDIR /app
# Copy modular JARs
COPY lib/*.jar /app/lib/
# Create runtime using jlink (optional - for smaller image)
# COPY myapp-runtime /app/runtime/
# Run application
CMD ["java", "--module-path", "lib", "--module", "com.example.main/com.example.main.Main"]
# Or using custom runtime
# CMD ["/app/runtime/bin/java", "--module", "com.example.main/com.example.main.Main"]

Key Benefits and Best Practices

Benefits:

  1. Strong Encapsulation: Hide internal implementation details
  2. Reliable Configuration: Explicit dependencies prevent runtime errors
  3. Scalability: Better organization for large applications
  4. Performance: Improved startup time and memory footprint
  5. Security: Reduced attack surface through encapsulation

Best Practices:

  1. Start with automatic modules for migration
  2. Use meaningful module names (reverse DNS)
  3. Keep modules focused and cohesive
  4. Use requires transitive for API modules
  5. Prefer qualified exports to limit accessibility
  6. Use services for loose coupling
  7. Test modules in isolation and integration

The Java Module System provides a robust foundation for building maintainable, scalable, and secure applications in Java.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper