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