Encapsulation in Java has been broken since the Java Development Kit (JDK) 5. Objects should guarantee that their inner state matches what we cast into code, but at runtime any private and final state can be set from the outside to any value — bypassing any checks and asserts. This is possible through the Reflection Api, which allows to setAccessible any private field and then set the value of any final field. This specific use of the Reflection Api to mutate an immutable final field is called deep reflection.
Deep reflection creates a discrepancy between what the code says and what actually happens at runtime. For example, the code might say that a number field is always greater than zero or that an email field contains a valid email address, but deep reflection means we cannot trust that to be true at runtime. The result is some of the most confounding bugs, which often only occur in special circumstances.
A couple of JDK developments will eventually rollback deep reflection, fix encapsulation and ensure that the code we read matches the code that is run. Some landed in JDK 15, some are landing in JDK 26 (March 2026), others will take a bit longer. One such development is destructoring — the breakdown of an object into its constituent parts — and it is key to Java’s ability to reliably maintain encapsulation and invariants. It is not yet in JDK and it will not be there for some time, but the plans that have been openly discussed are fascinating. The following chapters will detail how destructoring could work, why it would fix so many things in the JDK and how it would solve encapsulation.
Encapsulation
Encapsulation means that a code element guarantees it is always in a valid state.
Oracle Blog: Quiz Yourself
Encapsulation in particular means the guarantee of all invariants — statements about the element that are always true. Take the following code. The invariant we modelled here is that “Adults are of age 18 or older”. In certain situations displayAdult however prints 17 to System.out. There is a discrepancy between what the code says and what happens at runtime.
public class Adult implements Person {
private final Instant birthdate;
public Adult(Instant birthdate) {
this.birthdate = birthdate;
if(isYoungerThan18(birthdate))
throw new IllegalArgumentException("Person is not 18 yet.");
}
public long age(){ /** **/ }
}
void displayAdult(Adult adult){
IO.println(adult.age()); // prints 17 ??
}
The reason for the broken age invariant is a custom data migration that uses reflection. The migration clamps the age of faulty Child objects — buggy data we were supposed to fix — but it actually corrects all Persons (even Adults) via reflection. It breaks the encapsulation.
Reflection
The Reflection Api (java.lang.reflect) lets programs examine and modify runtime behavior of classes. It provides programmatic access to fields, methods, and constructors of loaded classes. This enables dynamic invocation, field access, and object instantiation without compile-time knowledge. With reflection, you can work with classes/methods known only at runtime. This is very useful for debuggers, frameworks, and serialisation libraries. The latter however being the cause of most encapsulation violations. Not through a fault on their own but because of the feature that was added to the JDK to support serialisation: deep reflection. It looks like this:
void main(){
// given an adult
var seventeenYearsAgo = now.minusYears(17);
var eighteenYearsAgo = now.minusYears(18);
var adult = new Adult(eighteenYearsAgo);
// when using reflection
Field birthdateField = adult.getClass().getDeclaredField("birthdate");
birthdateField.setAccessible(true);
birthdateField.set(adult, seventeenYearsAgo);
// then
IO.println(adult.age()); // prints 17
}
Reflection is available since JDK 1.1. Since JDK 1.2 we can setAccessible a private field and set a non-final field with set. This ability to mutate final fields via Reflection was first added in JDK 5 “so that third-party serialization libraries could provide functionality on par with the JDK’s own serialization facilities”. A choice that haunts the JDK to this day.
Because setAccessible and set allow illegal access to members of classes and even to break into JDK internals, the combination is highly discouraged and has received the name deep reflection. Deep reflection is one of many blockers not only for the maintainability of the JDK but also for the so called integrity by default. The JDK maintainers describe the motion as such:
Developers expect that their code and data is protected against use that is unwanted or unwise. The Java Platform, however, contains unsafe APIs that can undermine this expectation, thereby damaging the correctness, maintainability, scalability, security, and performance of applications. Going forward, we will restrict the unsafe APIs so that, by default, libraries, frameworks, and tools cannot use them.
Integrity by Default
Over the years they have delivered many improvements to the JDK to change the default towards integrity. One of these was the introduction of records. While classes allow mutation of final fields, records from the get-go do not. This is a huge benefit not only to developers — we can trust record fields to actually be immutable — but also because these fields can be “trusted for JIT optimization”. Since record fields are actually final the Java Virtual Machine (JVM) can perform an optimization once and trust it to work. For classes, the JVM has to continuously check whether the value has changed and potentially redo the optimization.
Clearly, deep reflection should not be the default. If anything, it should be up to us programmers whether or not to enable it. Prepare to make final mean final is a change delivered with JDK 26 (March 2026) that prepares for such a world. It does not disable deep reflection, but “issues warnings about uses of deep reflection to mutate final fields.”
If integrity should be the default, why has the encapsulation-breaking deep reflection been kept for so long in the JDK, and why is it not disabled by default? Because it is widely used for critical use cases for which no safe alternative exists — yet. One of these being serialization and third-party serialization. Until we have alternatives for these critical use cases we cannot disable deep reflection by default. Let’s see how they work.
Serialization
Java’s Serialization feature has garnered several years worth of security exploits and zero day attacks, earning it the nickname, “the gift that keeps on giving” and “the fourth unforgivable curse”.
State of Java Serialization
The goal of the Serialization Api was a slim mechanism for sharing an object’s representation across sockets or storing an object and its state for future retrieval (aka deserialization). It was designed in the days of the Common Object Request Broker Architecture (CORBA) and the Java equivalent — Remote Method Invocation (RMI) — and has been with us since JDK 1.1.
As Brian Goetz, Java Language Architect at Oracle, puts it: “(The Serialization Api) was probably critical to Java’s success — Java would probably not have risen to dominance without it, as serialization enabled the transparent remoting that in turn enabled the success of Java EE. Serialization itself is not the problem.
The problem is the design of the Serialization Api. Because the Api is cross-cutting, it must be considered for almost all new Java features, and can lead to security exploits all over the JDK. Maybe as much as half of all JDK vulnerabilities have involved serialization. It is “the gift that keeps on giving” and it works as shown below. With ObjectOutputStream.writeObject() you can serialize an object to a file or a socket, with ObjectInputStream.readObject() you can deserialize it and get your Java object back. Very simple to use, so clearly a good design right?
void main(){
var adult = new Adult(...);
// create ObjectOutputStream
try (var stream = new ObjectOutputStream(Files.newOutputStream(aFile))) {
// serialize file
stream.writeObject(adult);
}
}
public class Adult implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Instant birthdate;
public Adult(Instant birthdate) {
this.birthdate = birthdate;
if(isYoungerThan18(birthdate))
throw new IllegalArgumentException("Person is not 18 yet.");
}
public long age(){ /** **/ }
}
This design is flawed in many ways and has been discussed in many articles, including one with the lovely title serialization must die. The biggest gripe in this context is that the Api scrapes all fields for serialization — including private ones — and bypasses the constructor for deserialization. Like reflection, serialization breaks encapsulation. Unless you use a record. Here the spec requires calling the canonical constructor. The record designers clearly did not want to compromise the encapsulation of their new language construct.
If the Api is this bad, maybe we should never use it? There are indeed long-term plans to remove the Serialization Api from the JDK. The problem is of course that no safe alternative exists in the JDK and that all serious applications need some form of serialization. Data is always passed over the wire in some form or another. We need a way to go from Java objects to the outside world and back again. Which is why we have third-party serialization libraries and deep reflection in the first place.
Third-Party Serialization
The ability to mutate final fields via deep reflection was added in JDK 5 so that third-party serialization libraries could provide functionality on par with the JDK’s own serialization facilities. The JDK can deserialize an object from an input stream even if the object’s class declares final fields. It does this by bypassing the class’s constructors, which ordinarily assign instance fields, and assigning values from the input stream to instance fields directly — even if they are final. Third-party serialization libraries use deep reflection to do the same.
JEP 500: Prepare to Make Final Mean Final
Since JDK 5, third-party serialization libraries can set final fields, provided setAccessible(true) has succeeded. A bug report (JDK-5044412) documents this intentional change. Since then, third-party serlization can go deep into the bowels of an object, bypassing encapsulation, almost like the Serialization Api.
One notable difference is that reflection cannot bypass the constructor for object construction. But historically many libraries required having a no-argument constructor. Which means any parameter checks in the constructor were bypassed, encapsulation was still broken and we were back doing the same magic that the Serialization Api was doing.
This will change. With JEP 500, delivered in JDK 26, deep reflection will trigger warnings. Once an alternative exists, a future JEP will likely trigger errors by default and we developers will have to explicitly enable deep reflection. The default will move toward integrity. The mere existence of APIs that can modify final fields makes it impossible to trust the value of any final field.
Some libraries are already prepared for a future final means final future, some have not. Hibernate still requires a no-arg constructor for Entities for example. Meanwhile Jackson can deserialize just fine with the regular constructor, no deep reflection and without any annotation (we compare Hibernate and Jackson to show different approaches to serialization, not because ORMs and JSON mappers are in any way equivalent). Annotation-free with Jackson 2.x requires the compilerArgs -parameters and the ParameterNamesModule. Without the parameters flag reflection would not see the original parameter names street and city but instead arg0 and arg1.
The following example shows the serialization (mapper.writeValueAsString) and deserialization (mapper.readValue) in action.
class Address {
private final String street;
private final String city;
public Address(String street, String city) {
this.street = notBlank(street);
this.city = notBlank(city);
}
public String getStreet(){ return this.street; }
public String getCity(){ return this.city; }
}
String notBlank(String value){
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Value must not be blank");
}
return value;
}
void main() throws JsonProcessingException {
var original = new Address("Fancy Bvd", "Fancy Town");
var mapper = JsonMapper.builder()
// add module
.addModule(new ParameterNamesModule())
.build();
// serialize
var json = mapper.writeValueAsString(original);
// deserialize
var copy = mapper.readValue(json, Address.class);
assert Objects.equals(original.getStreet(), copy.getStreet());
assert Objects.equals(original.getCity(), copy.getCity());
}
This serialization is immensely better than the one that the Serialization Api does because the constructor is used and encapsulation is preserved. Should we try to deserialize something with a blank street {"street":"","city":"Cheap Town"} then our regular constructor will find the error and throw an exception.
If we use a record then this can be written even more concise:
record Address(String street, String city) {
Address {
notBlank(street);
notBlank(city);
}
}
static void notBlank(String value){
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Value must not be blank");
}
}
void main() throws JsonProcessingException {
var original = new Address("Fancy Bvd", "Fancy Town");
var mapper = JsonMapper.builder()
.addModule(new ParameterNamesModule())
.build();
var json = mapper.writeValueAsString(original); // serialize
var copy = mapper.readValue(json, Address.class); // deserialize
assert Objects.equals(original.street(), copy.street());
assert Objects.equals(original.city(), copy.city());
}
Interesting about the class and record (de-)serialization examples is that the parameters of the constructor and of the getters have to be linked by name. If the parameter is street, then the parameter-accessing method has to be called getStreet() or street() (for records but also for classes when you use accessorNaming without getter prefix). The sum of these accessors is the inverse of the constructor.
Typically you want the accessors to stay in sync with the constructor because serialization is often not unidirectional. You do not just serialize. You deserialize and serialize the same type of object because you typically send and receive the same type of object. Forget one accessor and the serialization breaks at runtime. Rename a constructor parameter and the deserialization breaks at runtime.
Taking things apart and putting things together are linked. But in Java they are only linked at runtime and only implicitly. Java provides just one half of the coin. The solution is obvious in hindsight. Construction and deconstruction need to be first-class citizens.
Deconstruction as first-class citizen
Many of the design (of the Serialization Api) stem from a common source — the choice to implement serialization by “magic” rather than giving deconstruction and reconstruction a first-class place in the object model itself.
Towards Better Serialization
Deconstruction is the process of taking an object apart into its constituent parts. For records we can already do that in limited circumstances with record patterns (JDK 21). We can write if(obj instanceof Point(int x, int y)){} and inside the if we have access to x and y. But this is not actual deconstruction. Behind the scenes the compiler just does:
- an
instanceOfcheck - a cast
var p = (Point) obj; - an assignment via accessor
var x = p.x() - another assignment via accessor
var y = p.y()
We have not defined the inverse of the constructor, we are still using regular accessors. It works for records because records are nothing but their state. The constructor and the accessors are linked because the compiler links them for us. The compiler can also infer how to take an object apart because it knows all the accessors.
This is different for classes. Here we developers have much more modelling freedom but the compiler can infer a lot less. The compiler of course knows what internal fields a class has but it cannot know if that is also what we want to expose. This is why we have no pattern matching for classes.
Take the following timestamp class. Its constructor takes an ISO 8601 formatted date string. Internally it stores it as a long because that is more efficient.
public class Timestamp {
private final long rawValue;
// Constructor, takes an utc date like 2026-01-13T12:31:15Z
public Timestamp(String utcDate) {
this.rawValue = parseUtcDateString(utcDate);
}
// accessor
public String utcDate(){
return formatAsUtcDateString(this.rawValue);
}
}
This is the freedom that classes bring. We can completely change the internal representation. We only have to maintain the external Api (the constructor and the accessor in this case). Classes decouple the Api from the internal representation. Classes can break up their state across a hierarchy. Classes can have derived or cached state that is not part of the state description. Also, classes are also mutable.. In short, classes have their place as data holders and can do things that records cannot do because they are much less constrained. We cannot say “just use records” because sometimes that does not work. We need proper deconstruction as a language construct.
So how would deconstruction look like in Java? What is the way to design it to fit the language style and let us co-locate “take me apart” and “put me back together”? In his 2025 talk Where is the Java Language Going and his 2019 article on Towards Better Serialization Brian Goetz sketches an idea (strawman syntax, not final by any means):
public class Timestamp {
private final long rawValue;
// Constructor
public Timestamp(String utcDate) {
this.rawValue = parseUtcDateString(utcDate);
}
// Deconstruction pattern
public pattern Timestamp(String utcDate) {
utcDate = formatAsUtcDateString(this.rawValue);
}
}
Instead of assigning to this we are now assigning this to the output. I like it because it makes deconstruction the visual inverse of the construction. It is very strange to assign something to a method parameter though. If you do not like the syntax, you can see another in Serialization – A New Hope and yet another in Data Oriented Programming, Beyond Records. The interesting thing is not the syntax, but what it enables.
This feature puts the person in charge of the deconstruction that has the most knowledge about the implementation: the class author. Libraries do not have to guess based on property names how to take the class apart. It is all in the hands of the author. Store however you want internally, provide a constructor, provide a deconstruction pattern and you are set. You do not have to guess if a class can be taken apart. If there is a deconstruction pattern, it is intended to be taken apart. It’s all cast in code and can be verified at compile-time. The deconstruction pattern makes a lot of use cases safe as well as readable and makes the previous method seem like a hack. The first of theses use cases is the complete replacement of the “cursed” Serialization Api.
(De-)Serialization through (De-)construction
A deconstruction pattern enables serialization, the process of translating an object’s state into a format that can be stored (see also Wikipedia). The constructor enables deserialization, extracting an object from a series of bytes. So while the original Serialization Api had numerous magic methods and fields (readObject, writeObject, readObjectNoData, readResolve, writeReplace, serialVersionUID, and serialPersistentFields) and Jackson has even more (@JsonAnyGetter, @JsonAnySetter, @JsonGetter, @JsonSetter, @JsonValue, @JsonRawValue, @JsonSerialize, @JsonDeserialize, @JsonCreator, @JsonAlias, @JsonIgnoreProperties, @JsonIgnore, @JsonIgnoreType, @JsonInclude, @JsonIncludeProperties etc.), the new Serialization in the JDK might just have two (again strawman syntax):
public class Point {
private final int x;
private final int y;
@Deserializer
public Point(int x, int y) {
this.x = x;
this.y = y;
}
@Serializer
public pattern Point(int x, int y) {
x = this.x;
y = this.y;
}
}
If we want we could also put this on a factory method:
public class Point {
private final int x;
private final int y;
private Point(int x, int y) {
this.x = x;
this.y = y;
}
@Serializer
public pattern Point(int x, int y) {
x = this.x;
y = this.y;
}
@Deserializer
public static Point of(int x, int y){
return new Point(x, y);
}
}
We could handle multiple versions like this:
public class Point {
private final int x;
private final int y;
@Deserializer(version = 2)
public Point(int x, int y) {
this.x = x;
this.y = y;
}
@Deserializer(version = 1)
public Point(int x) {
this(x, 0);
}
@Serializer(version = 2)
public pattern Point(int x, int y) {
x = this.x;
y = this.y;
}
}
The format of the serialization (JSON, Protobuf, database entity) would be up to the library. You could write the code once and then store or retrieve it from arbitrary sources. It would also make the libraries a lot simpler because the code already says how to take it apart.
We could of course also use this for pattern matching on classes and factories.
Pattern matching through Deconstruction
Pattern matching is really powerful with records, but using them means you tie the Api to your implementation. It also means you cannot pattern match most of the types in the JDK, because they are all modeled as classes. Should we get deconstruction pattern this power will finally be available to classes and it would look exactly like record patterns. Declare your deconstruction pattern and you can match to it:
class Address {
private final String street;
private final String city;
public Address(String street, String city) {
this.street = notBlank(street);
this.city = notBlank(city);
}
// deconstruction pattern
public pattern Address(int street, int city) {
street = this.street;
city = this.city;
}
}
String notBlank(String value){
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Value must not be blank");
}
return value;
}
void handle(Object obj) {
if(obj instanceOf Address(var street, var city)){ // use the deconstruction pattern
// do something with street and city
}
}
Pretty cool and once deconstruction pattern are in the language we might also get pattern assignments where you take apart a known class or record in a one-liner:
void handle(Address address){
Address(var street, var city) = address;
IO.println("Street is "+street);
IO.println("City is "+city);
// do something more useful
}
We might even apply pattern deconstruction to factories (see Where is the Java Language Going) allowing us to turn this verbose deconstruction:
void main(){
// construction
Optional<Shape> maybeShape = Optional.of(Ball.of(RED, 1));
// verbose deconstruction by hand
Shape s = maybeShape.orElse(null);
if(s != null && s.isBall() && (s.color() == RED)){
var ball = (Ball) s;
IO.printLn(ball.size() + " red balls");
}
}
into the following compact and clear code:
void main(){
// construction
Optional<Shape> maybeShape = Optional.of(Ball.of(RED, 1));
// compact matcher
if(maybeShape instanceOf Optional.of(Ball.of(RED, var count))){
IO.printLn(count + " red balls");
}
}
Taking things apart now looks like putting it back together.
When we get these patterns is of course open. As far as I am aware figuring out how deconstruction pattern works for classes with all the edge cases is the big blocker. Working with classes should handle the same as working with records. Once this is clear we will probably get a little thing called “derived creation”.
Derived Creation through Deconstruction
Records are immutable objects. To modify them we currently have to write so-called wither-methods by hand. It’s a bit cumbersome:
record Address(String street, String city){
Address {
notBlank(street);
notBlank(city);
}
Address withStreet(String street){
return new Address(street, this.city);
}
Address withCity(String city){
return new Address(this.street, city);
}
}
void main() {
var newAddress = anAddress
.withStreet("Cool Blvd")
.withCity("Cool City");
}
Derived Record Creation is a JDK candidate feature that would take the above code and shorten it to the following:
record Address(String street, String city){
Address {
notBlank(street);
notBlank(city);
}
// all handwritten withers now gone
}
void handle(Address anAddress) {
var newAddress = anAddress with { // strawman syntax
street = "Cool Blvd";
city = "Cool City";
}
}
Behind the scene the so-called “wither” deconstructs the address and reconstructs it with the changed values, always passing through the constructor and all the included checks. Java can deconstruct records because all state is public and it can reconstruct them because they have a singular constructor, the canonical constructor.
If we were to give classes a deconstruction pattern and a canonical constructor, then we could also “wither” classes which might look like this (strawman syntax, a recent alternative proposal is carrier classes):
class Address(String street, String city) { // possible canonical constructor
private final String street = street; // possible assignment
private final String city = city;
Address { // possible compact canonical constructor
notBlank(street);
notBlank(city);
}
// deconstruction pattern
pattern Address(int street, int city) {
street = this.street;
city = this.city;
}
}
void handle(Address anAddress) {
var newAddress = anAddress with {
street = "Cool Blvd";
city = "Cool City";
}
}
Again, very cool stuff. The JDK team — actually the Project Amber team — has been hard at work making this a reality. Derived record creation is one of the likeliest next Amber features. The blocker has been making this work with classes. They need a design general enough that it works for classes and records alike. Otherwise we end up with one method for records and another for classes.
Providing wither, patterns and serialization only to records is not an option because of the tight constraints that records require. If some of your data is not immutable, if you cannot couple Api to internal representation, or only some of your state should be exposed, you have to use a class. Once you use classes though “the user experience is like falling off a cliff. Even a small deviation from the record ideal means one has to go back to a blank slate and write explicit constructor declarations, accessor method declarations, and Object method implementations — and give up on destructuring through pattern matching”.
The Future
The best way to predict the future is to invent it.
Alan Kay
Destructoring, the method of deconstructing an object, is the future of Java’s Encapsulation. The examples above show how much safer and readable code can become once deconstruction is a first-class citizen in Java and reconstruction always utilizes the constructor. It enables simple and advanced use cases like serialization, class patterns, factory patterns, and of course derived record as well as class creation. All great features to have. The only question is now: Destructoring When?
Removing the legacy Serialization Api has been the plan since 2018, a rough idea exists since 2019 and deconstruction has been in the works since about 2024. The next JDK will be branched from the mainline June 2026 with a general availability (GA) in September 2026. The one after that is scheduled for GA in March 2027. Does that mean we will see destructoring in 2027? We shall see.
In the meantime you can check you out the accompanying code examples and see how to use Jackson without a no-args constructor and without annotations. Aside from that there are great articles and videos by the JDK team:
- 2019 Towards Better Serialization which explains the overall problem with the Serialization Api
- 2025 Where is the Java language going? which gives a good overview how the Java language architects think
- 2025 Prepare to make final mean final which explains more about the deep reflection problem
- 2026 Data Oriented Programming, Beyond Records which explains the new carrier class idea