JPMS Split Package — JVM Fails Fast, Not Silently
Split packages on module path cause ClassCastException at JVM startup.
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
- JPMS is a deployment unit above JARs, enforcing strong encapsulation at JVM level
- module-info.java declares dependencies (requires), exposed packages (exports), and services
- --add-opens grants reflective access at runtime, --add-exports grants compile-time access
- Split packages (same package in multiple modules) cause boot layer failure — leads to noisy startup errors
- Performance: module path classloading is slightly faster than classpath due to explicit dependencies
- Production pitfall: reflection-based frameworks (Spring, Hibernate) need --add-opens for deep introspection — missing these silently breaks DI at runtime
Imagine a giant LEGO factory where every team keeps their special bricks in a locked box. A team can only use another team's bricks if that team explicitly puts a label on the box saying 'these bricks are shareable.' Before Java 9, every brick from every team was just dumped on the floor — anyone could grab anything, even parts that were never meant to be touched. JPMS is the system of locked boxes and sharing labels that finally brought order to that factory floor.
If you've ever cracked open a production JVM heap dump and found a third-party library reaching deep into sun.misc.Unsafe, or spent a day debugging a ClassNotFoundException that only appeared when a JAR was repackaged, you already know the pain that JPMS was built to eliminate. The Java Platform Module System, shipped in Java 9 as part of Project Jigsaw after nearly a decade of design work, is the most structurally significant change to the Java platform since generics. It isn't a minor API addition — it's a new unit of deployment that sits above the JAR and below the application.
Before JPMS, the JVM had exactly one accessibility boundary at runtime: public. If a class was public, anyone on the classpath could use it, full stop. That meant internal JDK APIs like sun.reflect and com.sun.* were fair game for library authors who needed performance shortcuts, and there was no tooling that could enforce the architectural boundaries your team drew on whiteboards. The result was a fragile, monolithic JDK and codebases where 'refactoring an internal package' was a multi-sprint project because you never knew who was secretly depending on it.
By the end of this article you'll understand how the module system enforces strong encapsulation at the JVM level, how to author a correct module-info.java for a real multi-module project, how the module path differs from the classpath at the classloader level, where the system genuinely breaks down (split packages, reflective frameworks, legacy migration), and exactly what to say when an interviewer asks you to contrast --add-opens with --add-exports. This is the practical, internals-first guide that the official Javadoc never was.
What is JPMS? — The Core Idea
JPMS introduces a new level of structuring above packages: the module. A module is a named, self-describing collection of code and data that explicitly declares: - What it requires (dependencies on other modules) - What it exports (packages accessible to other modules) - What it provides (service implementations for service interfaces) - What it consumes (uses) of services
This is declared in a module-info.java file placed at the root of the source tree. When compiled, it becomes module-info.class inside the JAR. The JVM uses this metadata to enforce strong encapsulation: a module cannot access another module's internal packages unless they are explicitly exported.
opens io.thecodeforge.payment.dto to com.fasterxml.jackson.databind;opens X (or opens X to framework.module)exports Yexports Y and opens Y (or opens Y to ...)Module Path vs Classpath — How the JVM Loads Modules
When the JVM starts with the module path, it uses a new built-in module layer that merges all discovered module descriptors and resolves dependencies eagerly before any code runs. This is fundamentally different from the classpath approach, where class loading is lazy and the closest match wins.
- Module path (
--module-pathor-p): JVM scans all JARs and directories formodule-info.class, builds a module graph, checks for split packages, and fails fast on conflicts. - Classpath: Classic classloader (URLClassLoader) with linear search — last loaded can override earlier identical packages silently, leading to Heisenbugs.
JPMS also introduces the concept of unnamed module: any code on the classpath becomes part of the unnamed module, which can read all named modules but not the other way around (unless --add-reads is used). This is the bridge during migration.
- Module path: eager resolution, split package detection, strong encapsulation.
- Classpath: lazy loading, last-loaded-wins, no access control.
- Unnamed module: a fallback that can read all named modules but its code cannot be read by named modules.
Services — Loose Coupling with provides/uses
JPMS supports service loading via ServiceLoader that works across module boundaries. A module declares that it provides an implementation of a service interface, and another declares that it uses that service. The ServiceLoader discovers all implementations from all modules at runtime without compile-time dependency on the implementation class.
This is a departure from the traditional reflection-based discovery (like META-INF/services). The module system enforces that a module cannot provide a service if it doesn't also read the module that exports the service interface.
--add-opens. Prefer ServiceLoader for extensibility.provides ... with ....provides in module-info.provides declaration — the uses was there but the provider was silently missing.provides with the uses directive.Migration Strategy — Gradual Adoption of JPMS
Migrating a large codebase to JPMS in one go is risky. Oracle recommends a phased approach: 1. Classpath only — add module-info.java but put all JARs on classpath (the module descriptor is ignored). This lets you compile with module path but run with classpath. 2. Run with module path but keep all JARs that are not modularised on the classpath (unnamed module). Use --add-reads and --add-exports to bridge. 3. Make all JARs modularised — either by adding module-info.java to owned JARs or using automatic modules (JARs without module-info become automatic modules, reading all named modules). 4. Enforce strong encapsulation by removing unnecessary --add-exports and --add-reads.
Automatic modules are a temporary relief: a JAR on the module path without module-info becomes an automatic module that exports all its packages and reads all other modules. This can hide split-package issues.
jackson-core-2.12.jar and jackson-core-2.13.jar), the module system sees them as the same module and may pick one arbitrarily, causing ClassNotFoundException.jdeps --generate-module-info to create skeleton module-info from old JARs.jdeps --check catches most issues.Common Pitfalls and How to Fix Them
Beyond split packages, the most common production issues with JPMS include: - Transitive dependency on an internal JDK API — libraries that used sun.misc.BASE64Decoder now fail. Fix: use java.util.Base64 or --add-exports. - ClassLoader assumptions — some frameworks assume a single classloader or that all classes are from the classpath. JPMS uses multiple classloaders per module. If you see ClassNotFoundException from a framework that tries to load classes by reflection, add --add-opens and --add-reads. - Module name conflicts — two libraries use the same module name (e.g., com.google.common vs com.guava). The JVM throws an exception. Rename one with a custom module-info or use shaded JARs. - --add-opens security risk — granting ALL-UNNAMED reflective access opens up all packages to all code. In production, restrict to specific modules.
--add-exports flags.--illegal-access=deny (Java 9–16) or --add-opens tracking (Java 17+).--add-opens to specific modules, not ALL-UNNAMED.--illegal-access=deny in test.opens and exports in module-info.java--add-opens targeting that module--add-reads if neededModule Descriptor — The Contract Your JAR Must Sign
The module-info.java file is not a decoration. It is a signed contract with the JVM. Every module must declare exactly what it needs and what it exposes. No implicit visibility. No reflection by default. This is why legacy Spring Boot apps that rely on hacks like setAccessible() break immediately when modularized.
The descriptor lives at the root of your module's source tree. Inside, you declare requires for dependencies, exports for public packages, and opens if you must allow reflection (which most frameworks need). Every export is an explicit decision. Every reflection permission is a security risk you consciously accept.
For a Spring Boot 3.x service, you likely need requires java.sql, requires java.naming, and opens your entities package to org.hibernate.orm.core for ORM. Never open your entire module. Be surgical. Production incidents start when someone opens the whole module to "make it work" and then wonders why a serialization attack succeeded.
Module Types — Why Your Library Jar Just Became an Automatic Module
JPMS classifies modules into four types. Understand them because one wrong push turns your production JAR into an unnamed module — effectively a classpath refugee with zero encapsulation.
System modules ship with the JDK. You already use them: java.base is always present. Application modules are your own module-info.java files. Clean. Predictable.
Then come the headaches: automatic and unnamed modules. Any JAR on the module path that lacks a module-info.class becomes an automatic module. It reads all other modules and exports everything. It gives you the illusion of modularity with none of the safety. Worse, unnamed modules (legacy classpath JARs) cannot read named modules. That means if you leave one transitive dependency on the classpath, your module breaks silently.
In Spring Boot 3.x, most starters now ship with module descriptors. But older libraries or shaded JARs do not. That inner JAR inside a uber-JAR? It's anonymous. The JVM sees a blob. That's why Spring Boot's own layered JARs matter: they preserve module boundaries when done correctly.
jar --describe-module --file=your.jar to see if your dependency declares a module. If it returns 'No module descriptor found', that JAR becomes an automatic module the moment it touches the module path.ClassCastException at Startup: The Hidden Split Package
- Split packages are illegal on the module path — the JVM fails fast, not silently.
- When migrating, ensure every library either has a module-info or is properly shimmed with --add-reads and --add-exports.
- Use jdeps --module-path before deployment to detect split packages upfront.
java --list-modules | grep module_namejar --describe-module --file=module.jarKey takeaways
exports for direct access and opens for reflective access; never over-export.provides/uses directives in modular mode, not with legacy META-INF/services files.--add-opens to specific consumer modulesALL-UNNAMED in production.Common mistakes to avoid
4 patternsPutting all JARs on module path without verifying module-info
Using --add-opens ALL-UNNAMED in production as a quick fix
Assuming ServiceLoader works like pre-JPMS META-INF/services
provides directives, not the legacy file.provides service.Interface with io.thecodeforge.Impl to all modules that should provide implementations. Keep META-INF/services files only as fallback for classpath runs.Forgetting to add `requires` for transitive dependencies
requires transitive on the module that exports the dependency. Example: If module A exports package X that uses types from package Y (in module B), then A should requires transitive B so that any module reading A automatically gets access to B's exported packages.Interview Questions on This Topic
Explain the difference between --add-exports and --add-opens in JPMS.
--add-exports when a module needs to access another module's public types at compile time and runtime (e.g., using a public class from an internal JDK package). Use --add-opens when a module needs reflective access to non-public members (private fields, methods) at runtime only — common for frameworks like Spring, Hibernate, Jackson.
--add-exports java.base/sun.security.x509=ALL-UNNAMED allows all consumers to access that package's public classes. --add-opens java.base/java.lang=ALL-UNNAMED allows reflection on java.lang internals.Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
That's Advanced Java. Mark it forged?
5 min read · try the examples if you haven't