Skip to Content
Course content

183: Migrating a Legacy Application to Modules

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen this movie before. You inherit a massive, ten-year-old codebase—let's call it the InventoryManager system—and your boss tells you it's time to "modernize" it by moving to the Java Module System (JPMS). The app is a tangle of com.store.core, com.store.db, and com.store.api, all shoved into a single classpath where every class can see every other class. It's a wild west of dependencies.

The "Export Everything" Trap

When most developers first tackle this, they take the path of least resistance. They create a module-info.java for every project in the build, and because they don't want to spend three days fighting IllegalAccessException errors, they just export every single package in the project. It looks something like this:

module com.store.db {
    exports com.store.db.internal;
    exports com.store.db.connection;
    exports com.store.db.model;
    requires com.store.core;
}

I call this the "Hammer Approach." You've technically moved to modules, but you've gained nothing. You've just mirrored the existing classpath chaos in a more verbose format. The problem here is that you've leaked your internal implementation details. If com.store.db.internal contains a helper class that manages raw SQL strings, and you export it, some developer in the web module will inevitably start using that helper class directly. You've now codified your technical debt into the module system, making it nearly impossible to refactor the database layer without breaking the rest of the app.

Defining Strict Boundaries and Handling Reflection

The better way—the way that actually makes the migration worth the headache—is to treat module-info.java as a design document. Instead of asking "What needs to be visible so this compiles?", ask "What is the actual public API of this component?"

In our InventoryManager, the db module should only export the com.store.db.api package. Everything else—the connection pooling, the raw JDBC logic, the internal mapping—stays hidden. But here is where you'll hit a wall: reflection. If you're using Hibernate or Spring, those frameworks need to touch your private fields. If you just export the package, you're exposing it to the whole world. If you don't, the framework crashes.

This is where you use opens instead of exports. By using opens com.store.db.model to org.hibernate.orm.core, you're telling Java: "I don't want other modules calling these classes at compile time, but I'm allowing this specific framework to peek inside them via reflection at runtime." It's a surgical strike. You keep your architecture clean while satisfying the requirements of your dependencies.

The Upfront Cost of Correctness

Now, I'll be honest with you: the surgical approach takes significantly longer. You will spend an afternoon chasing down a single ClassNotFoundException because you forgot to require a transitive dependency. You'll find that some of your legacy code relies on "circular dependencies" (Module A needs B, and B needs A), which JPMS strictly forbids. You'll be forced to extract a third module, com.store.common, just to break the cycle.

It feels like busy work at first. But the trade-off is that you finally have a compiler-enforced architecture. When a new developer joins the team and tries to instantiate a database internal class from the UI layer, the code simply won't compile. You've moved the "don't do that" rule from a README file that nobody reads into the language itself. That's the real win.




📋 Practical Task

Refactoring the InventoryManager Module Boundaries

You have been handed a legacy project with two modules: com.store.core and com.store.db. Currently, com.store.db is exporting everything, including its internal connection logic, which is causing architectural leakage.

Your Task: Modify the module-info.java files to secure the application boundaries. Perform the following:

  • In com.store.db, remove the exports statement for com.store.db.internal.
  • In com.store.db, add an opens statement for com.store.db.model, granting access specifically to the org.hibernate.orm.core module to allow for database mapping without exposing the model to the rest of the application.
  • Ensure com.store.core still has a requires dependency on com.store.db.
  • Verify that a class in com.store.core can no longer import classes from com.store.db.internal.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.