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.