skip to content

How does the Java Platform Module System (JPMS), introduced in Java 9, enforce API versus implementation separation at the language and runtime level, and how is that different from just relying on the public access modifier?

level: seniorimportance: must knowfreq 45%

answer

  1. module-info.java exports clause
  2. strong encapsulation
  3. exports vs opens
  4. public no longer means visible everywhere
  5. sun.misc.Unsafe migration example

basics

~20 s

JPMS lets a module list, in a module-info.java file, exactly which packages it exports. A public class in a non-exported package is completely invisible outside the module - the JVM blocks access, not just a naming convention.

solid answer

~40 s

Before JPMS, public meant accessible from anywhere on the classpath - there was no way to say 'public within my jar but not to outside consumers' short of naming conventions, like an internal package, that nobody actually enforced. JPMS adds a module-info.java descriptor where a module declares exports com.foo.api to expose specific packages' public types to modules that require it, while non-exported packages such as com.foo.impl stay invisible to other modules at compile time, and, with strong encapsulation, at ordinary reflection time too. Deep reflection, like calling setAccessible(true) on a private field, additionally requires the package to be opens'd, separate from exports. This turns public-versus-internal from a documentation convention into something the module system itself enforces.

go deeper

for a junior

Not expected to know JPMS deeply; a passing answer just recognizes that Java has a module system letting you hide packages, going beyond plain public and private.

for a middle

Should know about module-info.java and the exports clause, and that a non-exported public class is inaccessible from outside its module.

for a senior

Should articulate exports versus opens, name strong encapsulation, explain the motivating sun.misc.Unsafe-style JDK-internals problem, and describe at least one concrete failure mode.

for a principal

Should discuss migration cost and ecosystem realities - automatic modules, --add-opens erosion - and connect JPMS's guarantees to the broader goal of minimizing a public contract at organizational scale.

## The mechanism The mechanism is a `module-info.java` file at the root of a module, declaring something like `module com.example.payments { exports com.example.payments.api; requires com.example.orders; }`. - Only the packages listed after `exports` are visible to other modules - even if the classes inside a non-exported package are individually marked public. - A class in `com.example.payments.impl` marked public is still completely invisible to any other module that requires `com.example.payments`, because that package was never exported. This is called **strong encapsulation**, and it is a genuinely new capability the classic classpath never had: on the classpath, public really did mean visible everywhere, with no way to restrict it further at the language level. ## exports versus opens A distinction worth making precisely is exports versus opens. | Clause | What it grants | |---|---| | `exports` | exports controls both compile-time access and ordinary reflective access to a package's public types. | | `opens` | opens, written as `opens com.foo.impl` or `opens com.foo.impl to SomeModule`, additionally permits deep reflection into that package at runtime - calling `setAccessible(true)` to bypass a private field or constructor - which many frameworks like Hibernate or Jackson rely on to read and write fields directly. | A module author can: - export a package for typed, compile-time access without opening it, blocking that framework-style deep reflection unless deliberately allowed, - or conversely open a package without exporting it, permitting reflection-based frameworks in without exposing a normal compile-time public API at all. ## Why it exists This exists to close a real, long-standing problem inside the JDK itself. Internal packages like `sun.misc.Unsafe` or various `com.sun.*` classes were technically public and heavily used by libraries across the ecosystem simply because nothing stopped them, which meant the JDK team could never safely change those internals without breaking huge swaths of downstream code. **JPMS** was built specifically to let the JDK, and any library author, draw a hard, JVM-enforced line between supported API and please-do-not-touch-this internal detail, enabling real internal evolution again. ## The trade-offs The trade-offs are substantial. Adopting JPMS is heavyweight: - every module needs a `module-info.java`, - the module graph must be acyclic and fully resolved, - and two modules can never both contribute classes to the same package name - a **split-package** error that breaks the build. Migrating a large pre-modular codebase is genuinely painful, and much of the Java ecosystem still ships as **automatic modules**, jars placed on the module path without their own module-info, which get inferred module names and largely bypass strong encapsulation - meaning the guarantees only hold cleanly when the whole dependency graph is properly modularized. Reflection-heavy frameworks that predate JPMS broke under strict enforcement until opens was added, and in practice many teams still open packages broadly out of expedience, quietly undermining the hidden half of the design. ## Failure modes in production 1. **The most common production failure mode is an `InaccessibleObjectException`** or similar error at runtime, usually triggered by a reflection-based library such as a JSON serializer trying to reach a private field in a package that was exported but never opened - code that worked fine on the plain classpath suddenly breaks the moment it runs as a proper module. 2. **Split-package errors** show up at startup when two jars define overlapping packages. 3. **Overuse of `opens ... to ALL-UNNAMED`**, or blanket `--add-opens` JVM flags used as a workaround, quietly defeats the entire encapsulation goal the same way making everything public again would. ## The flagship real-world example The JDK's own modularization in Java 9 is the flagship real-world example: `java.base`, `java.sql`, and similar modules export a curated set of packages while internals like `sun.*` and `jdk.internal.*` stay hidden, which forced libraries that had relied on those internals - many did, via `sun.misc.Unsafe` in particular - to migrate to supported replacements such as `java.lang.invoke.VarHandle`. A smaller-scale but very common pattern is a library author shipping `mylib.api`, exported, alongside `mylib.internal`, not exported, inside the same JPMS module, so the compiler and IDE autocomplete themselves physically prevent consumers from ever importing `mylib.internal.*` at all - a hard, JVM-enforced version of the api-module-versus-impl-module pattern achieved without needing two separate artifacts. ## Back to minimizing a public contract This connects directly back to the broader idea of minimizing a public contract. Because an exported package is now a real, JVM-enforced commitment rather than a documentation suggestion, JPMS pushes module authors toward exporting as little as possible - typically only genuine interfaces, DTOs, and exceptions - since un-exporting a package later is both a source-breaking and a binary-breaking change for anyone who came to depend on it. That is a sharper, less forgiving version of the same discipline required by a plain api/impl module split: with JPMS, the module system itself, not just team convention, is what makes exporting too much expensive to walk back.

  • What's the difference between a plain exports clause and a qualified exports ... to clause?
    A plain exports makes the package visible to every module that requires this one, with no restriction on who. A qualified exports ... to ModuleA, ModuleB restricts visibility to only the named modules, which is useful for internal helper packages meant for a small set of trusted sibling modules rather than the entire ecosystem - several JDK modules use exactly this to share internals only among themselves.
  • Why do so many real projects run with --add-opens flags instead of properly fixing their module-info.java?
    Usually because a transitive dependency, often a reflection-heavy framework or an older unmodularized library, needs deep reflective access into a package the application owner does not control and cannot add opens to themselves - for example a JDK-internal package. The application owner is then forced to punch a runtime hole with a JVM flag as a stopgap, which is the single most common way JPMS's guarantees get eroded in real deployments.

A gated office building where your business card might say 'open door,' but the building's own security list decides which floors visitors can actually reach - printing 'open' on a door sign no longer gets you in if security never cleared that floor for you.

saying these in an interview costs you the question

  • Believes public classes are always visible across modules under JPMS
  • Cannot distinguish exports from opens
  • Unaware that unmodularized jars fall back to automatic modules with weaker guarantees
  • Thinks JPMS enforcement is the same as Gradle's api/implementation split, which is build-time only

context