One of the most daunting challenges for enterprise teams adopting the Java Platform Module System (JPMS) is modularizing an existing monolithic application. A common misconception is that monoliths must be rewritten from scratch to fit the modular model. In reality, JPMS allows incremental migration, but developers often struggle with visibility errors, classpath vs module path confusion, and reflection-based frameworks breaking unexpectedly.
This case study walks through the practical journey of modularizing a real-world Java monolith, focusing on strategy, pitfalls, and best practices. For large enterprises and microservices migrations, this knowledge ensures that legacy systems can evolve without risky rewrites.
Think of modularization as renovating an old building while people still live in it—you must carefully restructure rooms (modules) while keeping the essential services running.
The Monolith: Initial Setup
Imagine an enterprise application with the following structure:
com.example.app
├── core
├── services
├── persistence
├── ui
└── util
Problems in the Monolith
- Everything runs on the classpath
- No encapsulation: internal APIs are accessible everywhere
- Utility classes abused across layers
- Frameworks (Spring, Hibernate) rely on reflection, creating fragile dependencies
Step 1: Incremental Modularization
Start with core modules:
module com.example.core {
exports com.example.core.api;
}
Keep dependent libraries on the classpath initially. Run the app with a hybrid approach:
java --module-path mods -cp lib/* -m com.example.core/com.example.core.Main
Step 2: Converting Services and Persistence
Add module-info.java
for services:
module com.example.services {
requires com.example.core;
exports com.example.services.api;
}
For persistence:
module com.example.persistence {
requires java.sql;
requires com.example.core;
exports com.example.persistence.api;
}
Step 3: Handling Reflection (Spring/Hibernate)
Frameworks often break because of encapsulation. Use:
--add-opens com.example.persistence/com.example.persistence.entities=org.hibernate.orm.core
Or explicitly:
open module com.example.persistence {
requires java.sql;
exports com.example.persistence.api;
}
Step 4: Creating a Custom Runtime with jlink
Once modularization stabilizes, build a custom runtime:
jlink --module-path $JAVA_HOME/jmods:mods --add-modules com.example.core,com.example.services,com.example.persistence --output myruntime
Pitfalls
❌ Trying to modularize everything at once → leads to chaos
❌ Forgetting reflection-heavy frameworks need opens
❌ Relying too long on automatic modules → fragile dependencies
❌ Mixing classpath and module path inconsistently
Best Practices
✅ Modularize incrementally: start with core APIs
✅ Use jdeps
to analyze dependencies
✅ Document --add-opens
and --add-reads
in CI/CD
✅ Favor explicit module-info.java
over command-line hacks
✅ Secure internal packages with proper exports
What's New in Java Versions?
- Java 5–8 → N/A (no JPMS)
- Java 9 → JPMS introduced, enabling modular migration paths
- Java 11 → Better tooling (
jdeps
,jlink
) for modularization - Java 17 → Improved performance and stability for modular builds
- Java 21 → No significant updates across Java versions for this feature
Real-World Analogy
Migrating a monolith with JPMS is like moving from an open-floor office to structured departments. Initially, everyone can walk into any room (classpath). With modularization, teams (modules) get their own rooms with access policies—making the office more secure and organized.
Summary & Key Takeaways
- Monoliths can be modularized incrementally without full rewrites
- Start with core APIs, then migrate services and persistence
- Handle reflection with
opens
or--add-opens
- Avoid overreliance on automatic modules
- Tools like
jdeps
,jlink
accelerate migration - Modularization improves encapsulation, maintainability, and deployment flexibility
FAQ: Modularizing Monoliths with JPMS
1. What is the difference between classpath and module path?
Classpath loads everything blindly, module path enforces explicit module dependencies.
2. Why do I get “package is not visible” errors?
Because JPMS enforces encapsulation. Use exports
or opens
as needed.
3. What is the purpose of requires transitive
?
It allows downstream modules to inherit dependencies automatically.
4. How do open
and opens
differ?open
makes the entire module reflectively accessible, opens
applies only to specific packages.
5. What are automatic modules, and should I use them?
They’re transitional tools to run non-modular JARs on the module path. Use sparingly.
6. How does JPMS improve security compared to classpath?
It prevents unintended access to internal APIs by default.
7. Should I use jlink or jmod in migration?
Use jlink
to create custom runtimes after modularization stabilizes.
8. Can I migrate legacy projects incrementally?
Yes, JPMS was designed with incremental migration in mind.
9. How do I handle third-party libraries that aren’t modularized?
Use automatic modules initially, or repackage with module-info.java
.
10. Do frameworks like Spring and Hibernate fully support modules?
Yes, but they may require --add-opens
for reflective features.