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:
- Strong Encapsulation: Hide internal implementation details
- Reliable Configuration: Explicit dependencies prevent runtime errors
- Scalability: Better organization for large applications
- Performance: Improved startup time and memory footprint
- Security: Reduced attack surface through encapsulation
Best Practices:
- Start with automatic modules for migration
- Use meaningful module names (reverse DNS)
- Keep modules focused and cohesive
- Use
requires transitivefor API modules - Prefer qualified exports to limit accessibility
- Use services for loose coupling
- Test modules in isolation and integration
The Java Module System provides a robust foundation for building maintainable, scalable, and secure applications in Java.