Case Study: Modularizing a Real-World Monolith

Illustration for Case Study: Modularizing a Real-World Monolith
By Last updated:

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;
}

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.