Introduction to Java Modules (Project Jigsaw)
The evolution of Java has always aimed at making the language and platform more efficient, scalable, and easier to manage. One of the most significant changes introduced in Java 9 was the module system, famously known as Project Jigsaw. This feature has transformed how I organize, build, and maintain Java applications, especially large ones, by providing a clear structure for modular development.
In this article, I will introduce the concept of Java modules, explain why they were introduced, and walk through the essentials of using the Java module system. I’ll share practical insights from my experience working with modules and highlight some challenges and best practices for effective modular programming.
Why Modularize Java Applications?
Before Java 9, the Java platform itself and most applications relied on the classpath, a simple but somewhat limited mechanism for loading classes and resources. The classpath worked well for many years but had some drawbacks:
- Monolithic JAR files: Large applications often packaged everything into huge JARs, making it difficult to manage dependencies and reuse parts.
- Dependency conflicts: Classpath made it easy for different libraries to bring conflicting versions of the same class, causing the infamous “classpath hell.”
- Poor encapsulation: Any public class was accessible to all code on the classpath, which meant you couldn’t hide internal implementation details effectively.
- Platform bloat: The Java runtime included a vast number of APIs, many of which were rarely used in typical applications, leading to unnecessarily large runtime footprints.
Project Jigsaw’s module system aimed to address these problems by introducing a new way to group code, declare dependencies, and control visibility, making applications more maintainable and scalable.
What Is a Java Module?
A module is a self-contained unit of code and resources with a well-defined boundary. It explicitly declares what it requires from other modules and what it exports to them. Think of a module as a strong encapsulation mechanism beyond traditional Java packages.
Modules have their own metadata, which specifies:
- Dependencies: Other modules it needs.
- Exports: Packages it makes accessible to other modules.
- Services: Optional service providers it offers or consumes.
This explicit structure helps the Java runtime enforce strong encapsulation and reliable configuration.
Anatomy of a Module: The module-info.java File
At the heart of every module lies a descriptor file named module-info.java. This file lives at the root of the module’s source folder and defines the module’s name, its dependencies, and exported packages.
Here’s a simple example:
java module com.example.myapp {
requires java.sql;
requires com.example.utils;
exports com.example.myapp.api;
}
In this declaration:
- The module is named
com.example.myapp. - It requires two other modules: the standard
java.sqland a customcom.example.utils. - It exports the package
com.example.myapp.apifor use by other modules.
The module system uses this information to enforce access rules and dependency correctness at both compile-time and runtime.
Creating Your First Module
Starting a modular project requires organizing your source files into module-specific directories.
Here’s a typical structure for a module named com.example.app:
cpp com.example.app/
├── module-info.java
└── com/
└── example/
└── app/
└── Main.java
The module-info.java might look like this:
java module com.example.app {
exports com.example.app;
}
This setup indicates that the module com.example.app exports the com.example.app package to other modules.
When compiling, the javac command is module-aware and respects the module boundaries.
bash javac -d out --module-source-path src $(find src -name "*.java")
Running modular applications requires the java command with the --module option:
bash java --module com.example.app/com.example.app.Main
This tells the runtime which module and main class to execute.
Benefits of Using Modules
The advantages I found when adopting modules include:
Strong Encapsulation
Unlike packages, modules prevent unintended access. Only the packages explicitly exported are accessible from outside. This reduces the chance of accidental dependencies and hides internal implementation.
Reliable Configuration
The module system detects missing or conflicting dependencies during compilation and runtime, eliminating many classpath-related issues.
Smaller Runtime Images
Using tools like jlink, I can create custom Java runtime images containing only the modules my application requires. This reduces the application size and improves startup time.
Better Maintainability
Modularization encourages clearer project structure and API design. It’s easier to evolve code without breaking clients since internal packages remain hidden.
Interacting with the Existing Classpath
Migrating legacy code to modules can be challenging. Java provides a transition mechanism called the unnamed module for code on the traditional classpath.
Code in the unnamed module can read all named modules, but named modules cannot read unnamed modules. This means you can use existing libraries without immediately modularizing them but cannot enforce modular boundaries fully.
I started modularizing by moving my code to named modules and leaving third-party libraries on the classpath. Over time, many popular libraries began providing modular JARs, simplifying the transition.
Module Dependencies and Transitive Requires
Modules can specify dependencies using the requires keyword. Sometimes, a module requires another module only because it re-exports some of that module’s API.
For these cases, the transitive modifier comes into play:
java module com.example.api {
requires transitive com.example.utils;
}
This means any module depending on com.example.api will implicitly depend on com.example.utils. This feature simplifies dependency declarations for API modules.
Services and Service Loaders
Modules support a service provider mechanism, enabling loose coupling between service interfaces and implementations.
A module can declare that it provides an implementation for a service interface:
java module com.example.provider {
provides com.example.service.MyService with com.example.provider.MyServiceImpl;
}
Consumers can declare that they use a service:
java module com.example.consumer {
uses com.example.service.MyService;
}
At runtime, ServiceLoader can be used to load implementations dynamically, allowing for flexible extensibility.
I have used this feature to build plugin architectures where new functionality can be added by providing service implementations.
Reflection and Modules
One of the initial concerns about modules was how they would interact with reflection, a core feature in many Java frameworks.
Modules restrict reflective access to only exported packages by default. To allow deep reflection (e.g., for frameworks like Spring or Hibernate), you can use the opens directive:
java module com.example.app {
opens com.example.app.internal to spring.core;
}
This statement opens the com.example.app.internal package to the spring.core module for reflection, without exporting it generally.
Challenges in Modularizing Large Projects
Despite the benefits, I found several hurdles when modularizing large legacy codebases:
- Splitting monolithic JARs: Deciding how to break large projects into meaningful modules can be complex.
- Third-party dependencies: Many libraries were not modularized initially, forcing me to use the unnamed module or create automatic modules.
- Split packages: Java forbids two modules exporting the same package, so restructuring packages was necessary.
- Tooling support: Build tools and IDEs initially lagged behind full support for modules, requiring workarounds.
However, these challenges are mostly resolved today with improved tooling and library support.
Best Practices for Using Java Modules
From my experience, here are some tips to get the most out of the Java module system:
- Design modules around cohesive functionality and clear API boundaries.
- Export only what is necessary; avoid exposing internal packages.
- Use
requires transitivecarefully to manage public dependencies. - Modularize incrementally, starting with your own code before third-party libraries.
- Leverage
jlinkto create optimized runtime images. - Test module boundaries using the
jdepstool to analyze dependencies.
How Modules Affect the Java Platform Itself
One of the major outcomes of Project Jigsaw was modularizing the Java platform itself.
Prior to Java 9, the platform was a monolith. With modules, the JDK is split into smaller modules like java.base, java.sql, java.xml, and many others.
This modular runtime allows developers to include only what is necessary for their application, trimming down resource usage and startup times.
From my perspective, this modularization enhances the portability of Java across devices, including constrained environments.
Real-World Use Cases
Modules shine in scenarios where large codebases need clean separation or where applications must be delivered with optimized runtimes.
For example:
- Microservices architectures where each service can be a module.
- Plugin systems that dynamically load modules at runtime.
- Embedded systems where runtime size is critical.
- Enterprise applications with well-defined APIs and internal logic encapsulated.
Conclusion
Java Modules introduced by Project Jigsaw represent a milestone in the evolution of Java, bringing clarity, encapsulation, and reliability to the platform. The ability to declare dependencies explicitly and control package visibility enhances both application design and runtime performance.
Although the transition requires planning and effort, especially for existing projects, the benefits far outweigh the initial costs.
By embracing modules, I’ve been able to write more maintainable, scalable, and efficient Java applications and I encourage you to explore this powerful feature for your projects.
