How does serialization differ for records compared to ordinary serializable classes?
answer
- Record stream = its components only
- Deserialization calls the canonical constructor
- Constructor invariants enforced on read (unlike normal classes)
- No readObject/writeObject for records
- readResolve/writeReplace still work
basics
~20 sRecords serialize using only their components, and deserialization always goes through the canonical constructor. So validation in that constructor runs on the way back in, and you cannot customize the process with the usual readObject/writeObject hooks the way you can for normal classes.
solid answer
~40 sFor a normal serializable class, deserialization reconstructs the object by allocating it without calling any constructor and setting fields reflectively, and you can hook in with writeObject, readObject, and readResolve. Records work differently: the serialized form is derived purely from the record components, and deserialization always invokes the canonical constructor with the component values. That has two big consequences. First, the invariants you enforce in the (compact) constructor are honored on deserialization - normal classes bypass the constructor, which is a classic source of broken invariants and gadget attacks. Second, you cannot use readObject/writeObject to customize the byte stream of a record; the only customization is readResolve/writeReplace for object substitution. You still add `implements Serializable` and a serialVersionUID. Net effect: record serialization is more secure and predictable, at the cost of less low-level control.
go deeper
Knows records can be made Serializable by implementing the interface.
Knows the serialized form is the components and that you don't write readObject/writeObject for records.
Explains that deserialization invokes the canonical constructor so invariants are enforced, and contrasts this with constructor-bypass in normal classes.
Weighs the security and compatibility implications of record serialization, schema evolution of components, and when a serialization proxy (writeReplace/readResolve) is still warranted.
### Background: how normal Java serialization works Java's built-in serialization turns an object into bytes and back. For an ordinary class that `implements Serializable`: - On write, the runtime reflectively reads the non-transient fields (or calls your `writeObject`). - On read, the runtime **allocates the object without calling any constructor** and sets its fields reflectively (or calls your `readObject`). You can also supply `readResolve` (replace the deserialized object) and `writeReplace` (replace the object being serialized). The constructor-bypass is powerful but dangerous: any invariant your constructor enforced (range checks, non-null, defensive copies) is **silently skipped** on deserialization, which is a well-known source of bugs and of deserialization 'gadget' security exploits. ### How records serialize A record that `implements Serializable` behaves differently and more safely: 1. **The serialized form is the components.** The stream is derived from the record components only - not from arbitrary fields, and the process ignores `writeObject`/`readObject` if you wrote them. 2. **Deserialization goes through the canonical constructor.** The runtime reads the component values from the stream and **calls the record's canonical constructor** with them. Because of (2), any validation/normalization in your compact or canonical constructor **runs on deserialization**: ```java record Range(int lo, int hi) implements java.io.Serializable { Range { if (lo > hi) throw new IllegalArgumentException(); // ALSO enforced on deserialize } } ``` A hostile or corrupt stream with `lo > hi` is rejected - something you'd have to hand-roll for a normal class. ### What you can and can't customize - **Cannot** use `writeObject`/`readObject` to change a record's stream representation - they're ignored for records. - **Can** still use `writeReplace` (serialize a stand-in object) and `readResolve` (substitute after read), which support the serialization-proxy pattern. - You still add `implements Serializable` and should declare a `serialVersionUID`. ### Trade-offs vs normal classes | Aspect | Normal class | Record | |---|---|---| | Construction on read | Constructor bypassed | Canonical constructor called | | Invariants enforced on read | No (unless hand-coded) | Yes, automatically | | writeObject/readObject hooks | Available | Ignored | | writeReplace/readResolve | Available | Available | | Field-level stream control | Fine-grained | Components only | ### Why it matters Record serialization is **safer by default** - it closes the constructor-bypass hole that underlies many deserialization vulnerabilities, and it keeps the round-trip honest (what comes back satisfies the same invariants as what went out). The cost is reduced low-level control: if you truly need custom stream layout you cannot get it directly on a record. In practice, prefer records for serializable value types precisely because the canonical-constructor guarantee makes them trustworthy across the wire.
- Why is record deserialization considered safer than ordinary class deserialization?Because it always runs the canonical constructor, the same validation and defensive copies you enforce on normal construction also apply when reconstructing from bytes - closing the constructor-bypass hole that enables many deserialization attacks and invariant violations.
- Can you customize a record's serialized form with writeObject/readObject?No. Those hooks are ignored for records; the form is fixed to the components. You can only substitute the whole object via writeReplace/readResolve.
saying these in an interview costs you the question
- Thinking record deserialization bypasses the constructor like normal classes
- Trying to customize a record's stream with readObject/writeObject
- Forgetting that compact-constructor validation also guards deserialization
- Assuming records can't be serialized at all