12.3 Reflection, Deep Encapsulation, `opens`, and Open Modules
Key Takeaways
Java 17 strictly enforces deep encapsulation by default, preventing reflective access to non-public members via AccessibleObject.setAccessible(true) unless a package is explicitly opened.
The 'exports' directive grants compile-time and runtime access only to public types and members; attempting deep reflection on an exported package throws InaccessibleObjectException.
The
opens <package>;directive permits runtime reflection without granting compile-time import access, making it the ideal choice for dependency injection and object-relational mapping frameworks.Qualified opens (
opens <package> to <module>;) restrict deep reflective inspection to specific designated consumer modules, preserving encapsulation against all others.Declaring
open module <name>opens every package in the module for reflective access at runtime; individual opens directives are forbidden inside an open module declaration.
Reflection, Deep Encapsulation, opens, and Open Modules
Reflection has long been a cornerstone of enterprise Java frameworks, powering dependency injection containers (Spring, CDI), object-relational mapping engines (Hibernate), and serialization libraries (Jackson, GSON). Prior to Java 9, reflective code could invoke AccessibleObject.setAccessible(true) on any Field, Method, or Constructor to bypass standard access control checks (private, protected, package-private). In Java 17 LTS, deep encapsulation is strongly enforced by default, requiring explicit module descriptors to authorize reflective access.
Deep Encapsulation Barriers in Java 17
Under Java 17, the JVM strictly differentiates between compile-time accessibility and runtime reflective access:
- Shallow Accessibility: Access to public types and public/protected members for direct invocation. Granted via the
exportsdirective. - Deep Reflection: Access to private, package-private, or protected members via
setAccessible(true). Blocked across module boundaries by default.
Why exports Alone Forbids Deep Reflection
A frequent misconception is that exporting a package opens it to reflection. If a module exports a package using exports com.example.model;, consuming modules can compile against and invoke its public members. However, if a framework attempts to inspect or mutate a private field via reflection:
// Inside a consuming module:
Class<?> clazz = Class.forName("com.example.model.Account");
Field balanceField = clazz.getDeclaredField("balance"); // private double balance;
balanceField.setAccessible(true); // THROWS EXCEPTION!
The JVM blocks the operation and throws:
java.lang.reflect.InaccessibleObjectException: Unable to make field private double
com.example.model.Account.balance accessible: module com.example.billing does not
"opens com.example.model" to unnamed module @5b2133b1
Warning
The InaccessibleObjectException Trap
An exports directive permits standard compilation against public members, but does not permit setAccessible(true) on non-public members from another module. To allow reflection to bypass access checks on private fields or constructors, the declaring module must use the opens directive.
The opens Directive
The opens directive is designed specifically to grant reflective access to a package at runtime without exposing it at compile time:
module com.example.billing {
requires java.sql;
opens com.example.billing.entities;
}
Key Characteristics of opens:
- Runtime Reflection Only: An
opensdirective does not export the package for compile-time access. Other modules cannot writeimport com.example.billing.entities.User;in their source files. Attempting to do so triggers a compile-time error. - Deep Reflective Access: At runtime, any module that reads
com.example.billingcan invokesetAccessible(true)on private, package-private, protected, and public members of types incom.example.billing.entities. - Combining
exportsandopens: A package can be simultaneously exported (for public compile-time APIs) and opened (for framework reflection):module com.example.billing { exports com.example.billing.dto; // Public types accessible at compile time opens com.example.billing.dto; // Deep reflection permitted at runtime }
Qualified Opens (opens ... to)
Granting unrestricted reflection to all modules can create security vulnerabilities. To mitigate this risk, JPMS provides qualified opens, which restrict deep reflection to specific designated consumer modules:
module com.example.service {
opens com.example.service.model to org.hibernate.orm.core, com.fasterxml.jackson.databind;
}
In this declaration:
- Only Hibernate and Jackson are permitted to invoke
setAccessible(true)on private members withincom.example.service.model. - Any other module attempting deep reflection on this package receives an
InaccessibleObjectExceptionat runtime. - The package remains completely hidden from compile-time imports across all modules.
Open Modules (open module)
When developing entity-heavy applications or migrating enterprise microservices, specifying individual opens directives for dozens of model packages becomes tedious. JPMS provides the open module syntax to open an entire module at once:
open module com.example.portal {
requires java.sql;
exports com.example.portal.api;
}
Rules Governing Open Modules:
- All Packages Opened: Every package in an
open moduleis automatically opened for deep reflection at runtime, including packages created dynamically or added in subsequent builds. - Selective Exporting: Packages within an
open moduleare not automatically exported for compile-time access. You still use explicitexportsdirectives to declare which packages form the public compile-time API. - Prohibition of
opensInside an Open Module: Because every package in anopen moduleis already open, declaring anopensdirective inside anopen moduleproduces a compile-time error:open module com.example.portal { // COMPILE ERROR: 'opens' only allowed in strong modules opens com.example.portal.internal; }
Comparison: exports vs. opens
The following matrix contrasts the capabilities and rules of exports and opens directives:
| Capability / Rule | exports <pkg> | opens <pkg> | open module <name> |
|---|---|---|---|
| Target Argument | Package name | Package name | Entire module |
| Compile-Time Public Access | Yes | No | No (requires explicit exports) |
| Runtime Public Access | Yes | Yes (via reflection) | Yes (via reflection) |
Runtime Deep Reflection (setAccessible) | No (InaccessibleObjectException) | Yes (all members) | Yes (all packages, all members) |
Qualified Syntax (to ...) | Supported (exports ... to) | Supported (opens ... to) | Not applicable |
Permitted Inside open module? | Yes | No (Compile error) | Not applicable |
CLI Flags and Programmatic Reflection Overrides
During testing or when transitioning legacy libraries that cannot be recompiled with modular descriptors, developers can dynamically modify module boundaries at JVM launch:
--add-opens <module>/<package>=<target-module>: Dynamically opens a package to a designated module (orALL-UNNAMEDfor tools running on the classpath) for deep reflection at runtime without editingmodule-info.java.--add-exports <module>/<package>=<target-module>: Dynamically exports a package to a designated module orALL-UNNAMED, granting public access to types at compile time or runtime.
Inspecting Module Boundaries via java.lang.Module
Java provides runtime reflection over the module graph via the java.lang.Module class:
Module myModule = getClass().getModule();
boolean isModelOpen = myModule.isOpen("com.example.billing.entities", frameworkModule);
boolean isApiExported = myModule.isExported("com.example.billing.api");
boolean readsSql = myModule.canRead(javaSqlModule);
These programmatic inspection methods allow runtime frameworks to verify that appropriate opens or exports directives have been configured before initiating deep reflective binding operations.
Module crm.data defines the following descriptor:
module crm.data {
exports com.crm.data.model;
}
Inside com.crm.data.model.Customer, there is a private field taxId. An external module, crm.framework, requires crm.data and executes:
Field field = Customer.class.getDeclaredField("taxId");
field.setAccessible(true);
Object val = field.get(customerInstance);
What is the result when running this code under Java 17?
The JVM prints a security warning to stderr and returns null without throwing an exception.
A runtime InaccessibleObjectException is thrown when executing field.setAccessible(true).
A compilation error occurs on the call to getDeclaredField("taxId").
The code executes successfully and retrieves the private taxId value.
Consider the following module declaration:
open module com.secure.vault {
requires java.base;
opens com.secure.vault.crypto;
}
What is the outcome of compiling this module-info.java file?
It fails compilation because opens directives are strictly forbidden inside an open module.
It fails compilation because open module declarations cannot declare dependencies on java.base.
It compiles successfully, opening only com.secure.vault.crypto for deep reflection.
It compiles successfully with a warning that the opens directive is redundant.
Module parser.json contains the following declaration:
module parser.json {
opens com.parser.json.config;
}
A consumer module, parser.app, declares requires parser.json;. In parser.app, a source file includes the import:
import com.parser.json.config.ConfigReader;
What is the result when compiling parser.app?
Compilation succeeds because opens grants full compile-time and runtime accessibility to public classes.
Compilation succeeds, but invoking any method on ConfigReader throws an IllegalAccessException at runtime.
Compilation fails because the opens directive permits only runtime reflection, not compile-time access.
Compilation succeeds only if ConfigReader is a public class with a public no-argument constructor.
Sections you finish are checked off in the contents.