Java Module System
Introduction
The Java Module System, also known as Project Jigsaw, was one of the most significant changes introduced in Java 9. Before Java 9, applications were built using JAR files that could access any public class from any other JAR on the classpath. This led to several issues including:
- JAR hell: Dependencies between JARs were implicit and could lead to version conflicts
- Lack of encapsulation: Any public class was accessible from anywhere
- Monolithic JDK: The JDK was a single, massive unit that couldn't be broken down
The Java Module System addresses these concerns by introducing a higher level of aggregation above packages called modules. Modules explicitly declare their dependencies and clearly define which packages they expose to other modules, providing stronger encapsulation and more reliable configuration.
Understanding Modules
A module in Java is a named, self-describing collection of code and data. It consists of:
- A unique name: Used to identify the module
- Packages: Groups of related classes and interfaces
- Module descriptor: A file that defines the module's requirements and exports
Key Concepts
- Module path: Similar to classpath but for modules
- Module descriptor: Defined in a file called
module-info.java
- Readability: One module reads another when it depends on it
- Accessibility: Types are accessible only when the package is exported and the module is readable
Creating Your First Module
Let's create a simple module example with two modules: one that provides a service and another that uses it.
Project Structure
my-modular-app/
├── greeting.provider/
│ ├── src/
│ │ ├── main/
│ │ │ ├── java/
│ │ │ │ ├── module-info.java
│ │ │ │ └── com/
│ │ │ │ └── example/
│ │ │ │ └── greeting/
│ │ │ │ └── GreetingProvider.java
│ └── build/
├── greeting.consumer/
│ ├── src/
│ │ ├── main/
│ │ │ ├── java/
│ │ │ │ ├── module-info.java
│ │ │ │ └── com/
│ │ │ │ └── example/
│ │ │ │ └── app/
│ │ │ │ └── GreetingApp.java
│ └── build/
Provider Module
First, let's create the provider module that offers greeting functionality:
module-info.java
module greeting.provider {
exports com.example.greeting;
}
GreetingProvider.java
package com.example.greeting;
public class GreetingProvider {
public String getGreeting(String name) {
return "Hello, " + name + "! Welcome to the Java Module System.";
}
// This method won't be accessible from outside the module
// even though the class is in an exported package and the method is public
public String getInternalGreeting() {
return "This is an internal greeting";
}
}
Consumer Module
Now, let's create the consumer module that uses the provider:
module-info.java
module greeting.consumer {
requires greeting.provider;
}
GreetingApp.java
package com.example.app;
import com.example.greeting.GreetingProvider;
public class GreetingApp {
public static void main(String[] args) {
GreetingProvider provider = new GreetingProvider();
String greeting = provider.getGreeting("Java Developer");
System.out.println(greeting);
}
}
Compiling and Running the Modular Application
# Compile the provider module
javac -d greeting.provider/build greeting.provider/src/main/java/module-info.java greeting.provider/src/main/java/com/example/greeting/GreetingProvider.java
# Compile the consumer module (with provider module in module path)
javac --module-path greeting.provider/build -d greeting.consumer/build greeting.consumer/src/main/java/module-info.java greeting.consumer/src/main/java/com/example/app/GreetingApp.java
# Run the application
java --module-path greeting.provider/build:greeting.consumer/build -m greeting.consumer/com.example.app.GreetingApp
Output
Hello, Java Developer! Welcome to the Java Module System.
Module Descriptor Details
The module-info.java
file defines a module's characteristics. Here are the most important directives:
exports
Makes packages accessible to other modules:
module my.module {
exports com.example.api; // Export to all modules
exports com.example.internal to // Export to specific modules only
another.module,
yet.another.module;
}
requires
Specifies dependencies on other modules:
module my.module {
requires java.sql; // Standard dependency
requires transitive java.xml; // Re-export this dependency
requires static java.desktop; // Compile-time only dependency
}
opens
Allows runtime access via reflection:
module my.module {
opens com.example.model; // Open to all modules
opens com.example.config to // Open to specific modules only
spring.core,
hibernate.core;
}
uses
and provides
For service consumption and provision:
module my.module {
uses com.example.spi.Service;
provides com.example.spi.Service with
com.example.impl.ServiceImpl;
}
Module Types
There are several module types you should be familiar with:
- Named modules: Regular modules with module-info.java files
- Automatic modules: Legacy JAR files placed on the module path (named based on JAR filename)
- Unnamed module: Everything on the classpath (can read all modules but not be read by named modules)
Module Patterns and Best Practices
Here are some common patterns and best practices when working with the Java Module System:
Aggregator Modules
A module that depends on several other modules and re-exports their APIs:
module com.example.aggregator {
requires transitive com.example.service;
requires transitive com.example.repository;
requires transitive com.example.util;
}
Service-Provider Interface (SPI) Pattern
Using the module system to implement plug-ins:
Service Interface Module:
module com.example.spi {
exports com.example.spi;
}
Service Implementation Module:
module com.example.impl {
requires com.example.spi;
provides com.example.spi.MyService with
com.example.impl.MyServiceImpl;
}
Service Consumer Module:
module com.example.consumer {
requires com.example.spi;
uses com.example.spi.MyService;
}
Real-World Example: Building a Modular Application
Let's build a more complete example - a simple calculator application with separate modules for:
- Core functionality (operations)
- User interface
- Persistence (saving calculation history)
Project Structure
modular-calculator/
├── calc.operations/
│ ├── module-info.java
│ └── com/example/calc/operations/
│ ├── BasicOperations.java
│ └── AdvancedOperations.java
├── calc.ui/
│ ├── module-info.java
│ └── com/example/calc/ui/
│ └── ConsoleUI.java
├── calc.persistence/
│ ├── module-info.java
│ └── com/example/calc/persistence/
│ └── HistoryManager.java
└── calc.app/
├── module-info.java
└── com/example/calc/app/
└── CalculatorApp.java
Operation Module
module-info.java
module calc.operations {
exports com.example.calc.operations;
}
BasicOperations.java
package com.example.calc.operations;
public class BasicOperations {
public double add(double a, double b) {
return a + b;
}
public double subtract(double a, double b) {
return a - b;
}
public double multiply(double a, double b) {
return a * b;
}
public double divide(double a, double b) {
if (b == 0) {
throw new ArithmeticException("Division by zero");
}
return a / b;
}
}
AdvancedOperations.java
package com.example.calc.operations;
public class AdvancedOperations {
public double power(double base, double exponent) {
return Math.pow(base, exponent);
}
public double sqrt(double value) {
if (value < 0) {
throw new IllegalArgumentException("Cannot calculate square root of a negative number");
}
return Math.sqrt(value);
}
}
Persistence Module
module-info.java
module calc.persistence {
exports com.example.calc.persistence;
requires calc.operations;
}
HistoryManager.java
package com.example.calc.persistence;
import java.util.ArrayList;
import java.util.List;
public class HistoryManager {
private List<String> history = new ArrayList<>();
public void addEntry(String operation, double result) {
history.add(operation + " = " + result);
}
public List<String> getHistory() {
return new ArrayList<>(history);
}
public void clearHistory() {
history.clear();
}
}
UI Module
module-info.java
module calc.ui {
exports com.example.calc.ui;
requires calc.operations;
requires calc.persistence;
}
ConsoleUI.java
package com.example.calc.ui;
import com.example.calc.operations.BasicOperations;
import com.example.calc.operations.AdvancedOperations;
import com.example.calc.persistence.HistoryManager;
import java.util.Scanner;
public class ConsoleUI {
private final BasicOperations basicOps = new BasicOperations();
private final AdvancedOperations advancedOps = new AdvancedOperations();
private final HistoryManager historyManager = new HistoryManager();
private final Scanner scanner = new Scanner(System.in);
public void start() {
boolean running = true;
System.out.println("Welcome to the Modular Calculator!");
while (running) {
System.out.println("\nAvailable operations:");
System.out.println("1. Add");
System.out.println("2. Subtract");
System.out.println("3. Multiply");
System.out.println("4. Divide");
System.out.println("5. Power");
System.out.println("6. Square Root");
System.out.println("7. Show History");
System.out.println("8. Exit");
System.out.print("\nEnter your choice: ");
String choice = scanner.nextLine();
switch (choice) {
case "1":
processAddition();
break;
case "2":
processSubtraction();
break;
case "7":
showHistory();
break;
case "8":
running = false;
System.out.println("Goodbye!");
break;
default:
System.out.println("Invalid option, try again.");
}
}
}
private void processAddition() {
System.out.print("Enter first number: ");
double a = Double.parseDouble(scanner.nextLine());
System.out.print("Enter second number: ");
double b = Double.parseDouble(scanner.nextLine());
double result = basicOps.add(a, b);
System.out.println("Result: " + result);
historyManager.addEntry(a + " + " + b, result);
}
private void processSubtraction() {
System.out.print("Enter first number: ");
double a = Double.parseDouble(scanner.nextLine());
System.out.print("Enter second number: ");
double b = Double.parseDouble(scanner.nextLine());
double result = basicOps.subtract(a, b);
System.out.println("Result: " + result);
historyManager.addEntry(a + " - " + b, result);
}
private void showHistory() {
System.out.println("\nCalculation History:");
List<String> history = historyManager.getHistory();
if (history.isEmpty()) {
System.out.println("No calculations yet.");
} else {
for (int i = 0; i < history.size(); i++) {
System.out.println((i+1) + ". " + history.get(i));
}
}
}
}
Application Module
module-info.java
module calc.app {
requires calc.ui;
}
CalculatorApp.java
package com.example.calc.app;
import com.example.calc.ui.ConsoleUI;
public class CalculatorApp {
public static void main(String[] args) {
ConsoleUI ui = new ConsoleUI();
ui.start();
}
}
Module Visualization
Here's a diagram showing the dependencies between our modules:
Benefits of the Module System
- Strong Encapsulation: Hide implementation details more effectively
- Reliable Configuration: Dependencies are explicit and verified at compile and build time
- Scalable Platform: The JDK itself is modularized, allowing for custom runtime images
- Better Performance: Startup time and memory footprint can be improved
- Enhanced Security: Reduced attack surface by limiting accessible code
Common Challenges and Solutions
Migrating Legacy Applications
When migrating existing applications to use modules:
- Start by turning your application into an automatic module
- Analyze dependencies and create module descriptors
- Address access issues incrementally
- Consider a bottom-up approach (dependencies first)
Split Packages
When the same package exists in multiple modules:
Problem: Package com.example.shared exists in modules module1 and module2
Solution: Merge the packages, rename one, or extract to a common module
Reflection Access
When frameworks need reflective access to your classes:
module my.domain {
// Open specific packages to allow complete reflection access
opens com.example.model to hibernate.core;
// Or open the entire module
// opens com.example.model;
}
jlink: Creating Custom Runtime Images
One major advantage of the module system is the ability to create custom, optimized runtime images with only the modules you need:
jlink --module-path $JAVA_HOME/jmods:mods --add-modules calc.app --output customimage
This creates a custom runtime with only the modules required by your application.
Summary
The Java Module System is a powerful feature that improves encapsulation, dependency management, and platform scalability in Java applications. Key benefits include:
- Explicit dependencies with module descriptors
- Strong encapsulation by controlling what's exposed
- Platform modularization allowing for smaller, targeted runtimes
- Service-based design with the
provides
/uses
mechanism
While it may require significant effort to modularize existing applications, the long-term benefits in maintainability, security, and performance make it worthwhile for many projects, especially large-scale enterprise applications.
Additional Resources
- The Java Module System (Oracle)
- Project Jigsaw (OpenJDK)
- Java Platform Module System Specification
Exercises
- Create a simple "Hello World" modular application with two modules.
- Modify the calculator example to add a new module for scientific calculations.
- Create a service-based application using the
provides
/uses
mechanism. - Build a custom runtime image for the calculator application using
jlink
. - Take an existing JAR application and try to modularize it step-by-step.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)