Strong encapsulation in JPMS is a deliberate trade-off. As a module designer, how do you decide what to `exports`, what to `opens`, and how do you keep the surface minimal?
answer
- Least exposure: default closed, export the thin API only
- Hidden internal packages = freedom to refactor
- Qualified opens to the exact framework, not blanket opens
- requires transitive only for re-exposed API types
- --add-opens/--add-exports = migration escape hatch, not design
basics
~20 sExport only the packages that form your public API; keep everything else internal. Open packages only for the specific reflection-based frameworks that truly need them, and qualify those opens to just those frameworks. The default should be closed.
solid answer
~50 sTreat exports/opens as your published contract, so the principle is least exposure. Export only the small set of API packages clients are meant to call; put implementation in non-exported `internal`/`impl` packages so you can refactor them freely without breaking consumers. Use `requires transitive` only when a dependency's types genuinely appear in your exported signatures, to avoid leaking your dependency graph onto clients. For reflection, prefer qualified `opens p to the.framework;` over unqualified `opens`, and avoid `open module` except for legacy. Keep `--add-opens`/`--add-exports` out of the normal design path — they are migration escape hatches that silently erode encapsulation and must be repeated at every launch. The payoffs are reliable, hidden internals, a smaller attack/coupling surface, fail-fast resolution, and the ability to evolve internals. The cost is upfront discipline and friction with reflection-heavy stacks, which qualified opens mitigates. Periodically audit the descriptor: every export and open is a maintenance commitment.
code
java · 10 linesmodule com.acme.orders {
requires com.acme.persistence; // internal only
requires transitive com.acme.money; // Money is in my public API
exports com.acme.orders.api; // thin public contract
exports com.acme.orders.spi to com.acme.plugins; // shared with our own module only
// com.acme.orders.internal.* -> NOT exported: free to refactor
opens com.acme.orders.api.dto to com.fasterxml.jackson.databind; // precise reflection
}go deeper
Understands you should expose only what's needed and keep the rest internal.
Separates API packages from internal packages and opens packages for frameworks, ideally qualified.
Applies least-exposure consistently, chooses requires/transitive and qualified exports/opens deliberately, and treats --add-opens as an escape hatch.
Owns module-API governance: versioning rules, qualified-export sharing between own modules, flag governance, security implications, and refactoring freedom enabled by hidden internals across a large multi-module system.
## What 'strong encapsulation' buys and costs In the **Java Platform Module System (JPMS)**, a **module** hides all its packages by default. You poke deliberate holes with two directives: - `exports p;` — lets other modules use the **public** types of package `p` normally (compile + run). - `opens p;` — lets other modules **reflect** into `p` at run time, including private members. The **trade-off**: strong encapsulation gives you (1) genuinely hidden internals you can refactor without breaking anyone, (2) a smaller coupling and attack surface, (3) fail-fast startup resolution (missing modules error immediately), and (4) reliable configuration. The **cost** is upfront discipline and friction with reflection-heavy frameworks that expect to reach into everything. Good module design maximizes the benefits while paying the cost only where necessary. ## Principle: least exposure (default closed) Everything in your `module-info.java` is a **published contract**. Each `exports` and each `opens` is a promise you must keep across versions. So expose the minimum: ```java module com.acme.orders { requires com.acme.persistence; // internal dependency, NOT transitive requires transitive com.acme.money; // Money appears in my public API exports com.acme.orders.api; // the contract clients call // com.acme.orders.internal.* : NOT exported -> free to refactor opens com.acme.orders.api.dto to com.fasterxml.jackson.databind; // only Jackson, only DTOs } ``` ### Deciding what to `exports` - Export the *thin* API package(s) consumers are meant to use. - Put implementation in `internal`/`impl` packages and **do not** export them — that is the whole point; you can change them freely. - Use **qualified exports** (`exports p to m1, m2;`) for packages that are shared between *your own* cooperating modules but should not be public to the world (a common pattern for multi-module products with a shared 'spi' or 'internal-shared' package). ### Deciding `requires` vs `requires transitive` - Use plain `requires` for dependencies that are purely internal implementation details — don't force them onto your clients. - Use `requires transitive` **only** when a dependency's types appear in your *exported* (public) signatures, so clients can name those types (implied readability). Over-using `transitive` leaks your dependency graph and couples clients to your choices. ### Deciding what to `opens` - Open a package **only** if a reflection-based framework (Jackson, Hibernate/JPA, Spring, serialization, validation, test tools) must reach private members in it. - Always prefer **qualified** `opens p to the.framework;` — it grants reflective access to exactly that framework and nothing else, preserving encapsulation against everyone else. - Reserve unqualified `opens p;` for when multiple/unknown frameworks need it. - Use `open module { }` (opens *every* package) only for legacy modules you can't restructure; it abandons package-level reflective encapsulation. - Keep reflected types (DTOs/entities) in their own packages so you can open *just* those, not your domain logic. ## Escape hatches are not design `--add-exports` and `--add-opens` (launcher/compiler flags, also expressible in `MANIFEST.MF` or `JDK_JAVA_OPTIONS`) let you punch holes into modules you can't edit. They are essential for **migration** and third-party integration, but as a *design* tool they are anti-patterns: they are invisible in the module descriptor, must be replicated in every run configuration (IDE, build, container, prod), and silently weaken encapsulation. Govern them: track every flag, justify it, and remove it once a proper directive or library upgrade is possible. ## Versioning and evolution discipline - Adding an export later is backward-compatible; **removing** one is a breaking change — so don't export speculatively. - Hidden internal packages can be renamed, split, or rewritten with zero client impact: that freedom is the main reward of encapsulation. - Audit the descriptor in review the way you'd review a public API change. A growing list of unqualified exports/opens is a smell that internals are leaking. ## Interplay with security Strong encapsulation reduces the reflective attack surface (e.g., libraries can't tamper with your internals) and makes 'internal' truly internal. Broad `open module` / `--add-opens ...=ALL-UNNAMED` reverses that benefit, so security-sensitive modules should keep opens tightly qualified. ## Decision checklist 1. Is this package part of the public contract? If not — don't export it. 2. Do my exported signatures expose another module's types? If yes — `requires transitive` that module; else plain `requires`. 3. Does a specific framework reflect into this package? If yes — qualified `opens to that.framework`; else don't open it. 4. Can I avoid a `--add-opens` flag by adding a proper directive or upgrading the library? Prefer that. 5. Could this package be shared only among my own modules? Use a qualified export. ## One-line takeaway Design for least exposure: export the thin API, hide the rest, open only the precise packages the precise frameworks need (qualified), keep `requires transitive` to genuinely re-exposed types, and treat `--add-opens` as a temporary escape hatch — every directive is a contract you must maintain.
- Why is removing an `exports` a breaking change but adding one is safe?Clients compile and run against exported public types. Removing an export hides types they depend on, breaking their compilation/runtime — a major-version change. Adding an export only widens what's available and breaks no existing consumer, so it's backward-compatible. This asymmetry is why you should export conservatively and never speculatively.
- When is `open module` justified over individual qualified opens?Mainly for legacy modules being migrated where many packages are reflected over and restructuring them is impractical, or short-lived/internal modules where reflective encapsulation has little value. It trades away package-level encapsulation for convenience, so for long-lived, security-relevant, or public modules you should prefer narrowly qualified `opens` instead.
A module descriptor is like a building's published floor plan for visitors: you mark only the lobby and meeting rooms as open to guests (exports), give a cleaning crew a key to one storeroom (qualified opens), and keep the rest off-plan so you can renovate it anytime without renegotiating with anyone.
saying these in an interview costs you the question
- Exporting all packages 'to be safe' — that defeats encapsulation and locks in internals as contract.
- Using `requires transitive` for ordinary internal dependencies, leaking the dependency graph to clients.
- Treating `--add-opens`/`open module` as the normal way to integrate frameworks instead of qualified opens.
- Forgetting that every export/open is a versioned contract whose removal breaks consumers.