skip to content

Why is the canonical constructor the right place to enforce a record's invariants, and how does that interact with deserialization and equals/hashCode?

level: principalimportance: nice to knowfreq 38%

answer

  1. All construction funnels to the canonical constructor
  2. Record deserialization runs the canonical constructor
  3. Closes the serialization-bypass loophole
  4. equals/hashCode derived from components
  5. Validate once, prefer immutable components

basics

~10 s

All ways of building a record run its canonical constructor, including Java deserialization, so validation placed there always runs. That keeps every instance valid and keeps the auto-generated equals/hashCode consistent with the components.

solid answer

~50 s

Records make the canonical constructor the single funnel for construction: overloaded constructors must delegate to it, and — unlike ordinary serializable classes — record deserialization is required to go through the canonical constructor rather than reconstructing state field-by-field via reflection. That means any validation, normalization, or defensive copying you put in the canonical (compact) constructor also runs when an instance is rebuilt from a serialized stream, closing the classic loophole where deserialization could fabricate an object that bypassed all constructor checks. This matters because a record's `equals`/`hashCode`/`toString` are derived from its component values; if the components can hold invalid or mutable state, equality and hashing become unstable, breaking the record as a map/set key. So the canonical constructor is both the invariant gate and the guarantor that derived methods are meaningful. The principal-level takeaway: prefer immutable component types and validate-once in the canonical constructor to get a value type that is safe, serialization-robust, and a sound hash key.

code

java · 14 lines
java
import java.io.Serializable;
import java.util.List;

record Money(String currency, long cents) implements Serializable {
    Money {
        if (currency == null || currency.length() != 3)
            throw new IllegalArgumentException("bad currency");
        if (cents < 0) throw new IllegalArgumentException("negative");
    }
}
// Deserializing a Money from a tampered stream still runs the canonical
// constructor, so an invalid currency/negative cents is REJECTED on read --
// no custom readObject needed. equals/hashCode (derived from currency+cents)
// are therefore always computed from validated component values.

go deeper

for a junior

Knows validation goes in the constructor so bad data is rejected at creation.

for a middle

States that all record constructors funnel to the canonical one, so validation lives there once.

for a senior

Explains that record deserialization also runs the canonical constructor and links validation to stable equals/hashCode for keys.

for a principal

Frames the canonical constructor as the unified invariant + serialization-safety gate, sets a convention of immutable components and validate-once, and reasons about value-type design and attack surface.

## The single-funnel construction model A **record**'s **canonical constructor** is the one constructor whose parameters match the components and the only one that assigns the component fields. The language enforces that: - every **non-canonical constructor** delegates to it via `this(...)`; and - the **compact** form lets the compiler insert the field assignments at the end, after your validation runs. So no matter how a record is built in normal code, its canonical constructor — and therefore your validation — runs exactly once. ## The historical serialization loophole (for ordinary classes) Java **serialization** turns an object into a byte stream and **deserialization** rebuilds it. For ordinary `Serializable` classes, the default deserialization machinery **does not call your constructor** — it allocates the object and sets fields directly via reflection. This is a notorious hole: an attacker (or a corrupted stream) can produce an instance whose fields violate the invariants your constructor would have rejected. Defending against it historically required writing `readObject`/`readResolve` and validating again, a step easy to forget. ## How records close the loophole Record **deserialization is specified to go through the canonical constructor**. The stream's component values are read and then passed to the canonical constructor, so your validation, normalization, and defensive copies run on the deserialized data just as they do on freshly constructed data. You get serialization safety **for free**, without writing custom `readObject` logic. (Records also have a simplified, component-based serialized form rather than the default object-graph form.) ## Why this ties back to equals/hashCode A record's `equals`, `hashCode`, and `toString` are **auto-derived from the component values**. Two consequences: 1. **Correctness depends on valid components.** If a component can be `null` when it shouldn't, or hold a half-built value, the derived methods produce surprising results. Validating in the canonical constructor guarantees every live instance has sensible components, so the derived methods are meaningful. 2. **Hash-key stability depends on immutable components.** `equals`/`hashCode` read mutable component objects live. If a component mutates after the record is used as a `HashMap`/`HashSet` key, its hash changes and the entry is effectively lost. Defensive copying in the canonical constructor (e.g. `List.copyOf`) freezes the component so the derived hash is stable — making the record a sound key. ## The principal-level design stance - **Validate once, in the canonical constructor.** Because all paths (overloads and deserialization) funnel through it, there is exactly one place to maintain invariants — no scattered re-checks. - **Prefer immutable component types** (`List.copyOf`, `Instant`, value wrappers) so defensive copying and key-stability are automatic and you avoid overriding accessors. - **Be deliberate about serialization.** Records remove the constructor-bypass loophole, but you still control whether the type is `Serializable` at all; only opt in when you need it. - **Treat the record as a value type:** a small, validated, immutable bundle whose identity is its contents — which is exactly what derived `equals`/`hashCode` express. In short, the canonical constructor is simultaneously the invariant gate, the serialization-safety guarantee, and the foundation that makes the auto-generated value semantics trustworthy.

  • How does record deserialization differ from default serialization of a plain class?
    Default class deserialization sets fields by reflection and skips the constructor, which can bypass invariants. Record deserialization is specified to pass the component values through the canonical constructor, so validation/normalization run on the deserialized data automatically.
  • Why does validating in the canonical constructor make the record a safer map key?
    equals/hashCode are derived from components. Validation plus defensive copying ensures components are valid and immutable, so the hash is stable and the entry stays retrievable; mutable/invalid components would make the key unstable.

The canonical constructor is the customs checkpoint at the only border crossing into a country. Whether travelers arrive by the front road (new), a side road (other constructors), or are airlifted in from an old archive (deserialization), they all clear the same customs — so no unvetted instance ever gets inside.

saying these in an interview costs you the question

  • Claiming records still need a custom readObject to re-validate on deserialization (they run the canonical constructor instead).
  • Assuming deserialization bypasses record validation the way it can for ordinary classes.
  • Validating in every overloaded constructor instead of once in the canonical one.

context