Java Module System Techniques for Modular Applications
I’ve spent years wrestling with Java’s classpath hell. When the Module System arrived, I approached it skeptically. Now? I won’t build without it. Modules enforce boundaries that keep projects sane as they scale. Forget theoretical fluff—here’s how I use it daily.
1. Basic Module Declaration
Start simple. Your module-info.java
is the contract. I treat exports
like API endpoints—only expose what’s necessary. See this inventory module:
module com.example.inventory {
requires com.example.logging;
exports com.example.inventory.api;
}
I once exported an entire package for “convenience.” Big mistake. When internal classes got coupled externally, refactoring became impossible. Now I export only .api
packages. The requires
clause? Be explicit. Your module won’t resolve if dependencies aren’t found at compile time.
2. Service Provider Configuration
Services decouple interfaces from implementations. Here’s how I set up a logger service:
module com.example.logging {
provides com.example.Logger
with com.example.FileLogger;
}
In your consumer module:
module com.example.app {
uses com.example.Logger;
}
I use ServiceLoader.load(Logger.class)
to access implementations. Why bother? Swapping loggers during testing becomes trivial. Just ensure your service implementations aren’t exported—keep them internal.
3. Reflective Access Control
Frameworks love reflection. Instead of open module
(which exports everything), I use targeted opening:
module com.example.webapp {
opens com.example.internal to com.example.framework;
}
When Spring demanded reflection access, this prevented exposing database models to unrelated modules. If a framework insists on full access, question its design.
4. Automatic Module Migration
Legacy JARs haunt us all. To integrate a non-modular legacy.jar
:
jar --describe-module --file legacy.jar
Output might show:
No module descriptor. Automatic module name: legacy
Then declare:
module com.example.app {
requires legacy;
}
I rename messy JARs like spring-core-5.3.jar
to spring.core.jar
first. Better yet, add Automatic-Module-Name
to the manifest.
5. Module Layer Isolation
Plugins need isolation. Here’s how I layer them:
ModuleLayer bootLayer = ModuleLayer.boot();
ModuleFinder pluginFinder = ModuleFinder.of(Paths.get("plugins"));
Configuration config = bootLayer.configuration()
.resolve(pluginFinder, ModuleFinder.of(), Set.of("payment.plugin"));
ModuleLayer pluginLayer = bootLayer.defineModulesWithOneLoader(config, ClassLoader.getSystemClassLoader());
I use this for payment gateways. Each plugin gets its own layer, preventing dependency clashes. If a plugin misbehaves, it won’t tank the whole app.
6. JLink Runtime Customization
Deploying 300MB JVMs for microservices? Madness. Build lean:
jlink --module-path mods/:$JAVA_HOME/jmods \
--add-modules com.example.app \
--output custom-runtime \
--strip-debug --compress=2
My Docker images shrunk 70% using this. Pro tip: Use jdeps --list-deps
first to find exact dependencies.
7. Optional Dependency Handling
Some dependencies are compile-only. Mark them static
:
module com.example.data {
requires static com.example.cache;
}
In code, guard cache usage:
if (ModuleLayer.boot().findModule("com.example.cache").isPresent()) {
// Use cache
}
I do this for metrics libraries—optional at runtime. No more ClassNotFound
if the cache isn’t deployed.
8. Multi-Release JAR Integration
Need Java 11+ features but maintain Java 8 compatibility? Multi-release JARs work with modules. Key rule: Put module-info.class
in the root, not versioned directories.
jar-root
├── module-info.class
├── com
└── META-INF
└── versions
└── 11
└── com
I use this for Java 17 vector APIs while supporting LTS users.
9. Module Path Troubleshooting
When modules mysteriously fail:
Module module = getClass().getModule();
ModuleDescriptor descriptor = module.getDescriptor();
descriptor.requires().forEach(req ->
System.out.println("Requires: " + req.name()));
This saved me when a requires transitive
dependency wasn’t re-exported properly. Also try --show-module-resolution
at launch.
10. Migration from Classpath
Transition incrementally. Mix classpath and module path:
java --module-path mods --classpath libs/* --module com.example.app/com.example.Main
For split packages (same package in multiple JARs), use --patch-module
:
java --patch-module com.example.common=common-legacy.jar:common-new.jar ...
I migrated a 200k LOC monolith over six months this way. Start with leaf modules—no dependencies on others.
Why This Matters
Modules prevent “works on my machine” syndrome. Last month, a junior dev added a conflicting JSON library. The module system blocked it at startup—no runtime surprises. It’s like dependency injection for your architecture: explicit contracts, no hidden couplings.
Start small. Convert one service. Feel that satisfaction when jlink
produces a 40MB runtime. You’ll never go back.