How do Java records help build immutable classes, and what do they still NOT do for you?
answer
- record = final class + private final fields + accessors + equals/hashCode/toString
- covers 3 of 4 checklist steps; copying is still on you
- compact constructor: validate + copy IN (reassign the parameter)
- override accessor to copy OUT for mutable components
- can't extend a class, can't add extra instance fields
basics
~20 sA record automatically makes the class final, makes its fields private and final, and gives you a constructor, getters, equals, hashCode and toString — so you get most of the immutable checklist for free. But it does NOT copy mutable fields, so if a field is a List or Date you still have to copy it yourself.
solid answer
~50 sA `record` is a concise way to declare a transparent carrier of data. From its component list the compiler generates: an implicitly **final** class, **private final** fields, a canonical constructor, an accessor per component, and value-based `equals`, `hashCode`, and `toString`. That covers checklist items 1–3 (final class, private final fields, no setters) automatically. What it does **not** do is **defensive copying** — if a component is a mutable type (array, `Date`, `ArrayList`), the canonical constructor stores the caller's object directly and the accessor returns it directly, so the object can leak and be mutated. To close that you write a **compact constructor** (`Period { dates = List.copyOf(dates); }`) to validate and copy on input, and override the accessor to copy on output. Records also can't extend a class, can't have additional instance fields beyond their components, and aren't ideal when you need a mutable builder or hidden derived state — in those cases a hand-written immutable class or a builder may fit better.
code
java · 13 linesimport java.util.*;
record Period(List<LocalDate> dates) {
// compact constructor: validate + defensively copy IN
Period {
Objects.requireNonNull(dates, "dates");
dates = List.copyOf(dates); // reassign param; compiler does this.dates = dates
if (dates.isEmpty())
throw new IllegalArgumentException("dates empty");
}
// accessor can return the field directly because List.copyOf produced an immutable list
// (for a mutable component like Date[], you would copy again here)
}go deeper
Knows a record is a short way to make a data class and that it's immutable-ish, but may not know the defensive-copy gap.
Lists what records generate (final, private final fields, accessors, equals/hashCode/toString) and knows you can validate in a compact constructor.
Explains that records skip defensive copying, writes a correct compact constructor (reassign the parameter) plus accessor override, and knows the copy-then-validate ordering.
Decides when a record fits vs a hand-written class or builder, weighs the no-inheritance/no-extra-fields limits, and standardizes the team's immutable-value-type approach including the mutable-component caveat.
## What a record is A **record** (standard since Java 16) is a special kind of class declared by listing its data fields, called **components**, in the header: ```java record Point(int x, int y) { } ``` The phrase "transparent carrier for data" is the intent: a record's API *is* its state. From that one line the compiler generates a lot of boilerplate. ## What the compiler generates (the free wins) For `record Point(int x, int y)` you get, automatically: - The class is **implicitly `final`** — you can't subclass it. (Checklist step 1.) - A **`private final` field** per component: `private final int x; private final int y;`. (Checklist step 2.) - A **canonical constructor** `Point(int x, int y)` that assigns each field. - An **accessor** per component named like the component: `x()`, `y()` (note: not `getX()`). - Value-based **`equals`**, **`hashCode`**, and **`toString`** derived from all components. - **No setters** are generated, and you can't add field mutation cleanly. (Checklist step 3.) So three of the four checklist items — final class, private final fields, no setters — come for free. That's why records are the default modern way to write small immutable value types. ## The one thing it does NOT do: defensive copying The record's generated canonical constructor does the equivalent of `this.x = x;` for each component — a plain reference assignment. If a component is a **mutable** type, that assignment **aliases** the caller's object, and the generated accessor returns the field **directly**. Both the input and output leaks (see defensive-copying) are wide open: ```java record Period(List<LocalDate> dates) { } List<LocalDate> mine = new ArrayList<>(...); Period p = new Period(mine); mine.clear(); // input leak: mutates p p.dates().add(...); // output leak: mutates p's internal list (if it's mutable) ``` Records give you immutability of the *references* (final fields) but not of the *referents*. ### Fixing it: the compact constructor + accessor override The **compact constructor** is a record-only form that omits the parameter list and the trailing field assignments — the compiler inserts the assignments for you *after* your code runs. It's the canonical place to **validate** and **defensively copy on input**: ```java record Period(List<LocalDate> dates) { Period { // compact constructor Objects.requireNonNull(dates); dates = List.copyOf(dates); // reassign the PARAMETER; implicit this.dates = dates follows } public List<LocalDate> dates() { // override accessor for copy-out return dates; // List.copyOf already made it immutable, so this is safe } } ``` Key mechanics: inside a compact constructor you reassign the **parameter** (`dates = ...`), and the compiler's implicit `this.dates = dates;` then stores your cleaned-up value. Because `List.copyOf` returns a truly **immutable** list, the accessor can even return it directly — no second copy needed. For a mutable type with no immutable equivalent (like `Date` or `int[]`), you'd copy in the compact constructor **and** copy again in the overridden accessor. ## Validation lives in the compact constructor too Beyond copying, the compact constructor is where you enforce invariants — null checks, range checks, cross-field rules (`if (start.isAfter(end)) throw ...`). Throwing there means an invalid record can never exist. Validate the **copy** (after `List.copyOf`), preserving the copy-then-validate ordering that avoids the TOCTOU race on mutable inputs. ## Limits of records (when NOT to use one) - A record **can't extend** another class (it implicitly extends `java.lang.Record`); it can implement interfaces. - It **can't declare extra instance fields** beyond its components — all state is the components. (Static fields are allowed.) - Its `equals`/`hashCode` use **all** components; if you need to exclude one, a record is awkward. - For objects with many optional fields, a **builder** on a hand-written immutable class reads better than a giant canonical constructor. - If you genuinely need a mutable type or inheritance, a record is the wrong tool. ## Bottom line Reach for a record for small immutable value types — it eliminates the boilerplate for three of the four checklist steps. Just remember the fourth step is still yours: **defensively copy any mutable component in the compact constructor and the accessor.**
- Inside a compact constructor, how do you defensively copy a component?Reassign the constructor parameter, e.g. `dates = List.copyOf(dates);`. You do NOT write `this.dates = ...` — the compiler emits the `this.dates = dates;` assignment automatically after your compact-constructor body runs, so it stores whatever you reassigned the parameter to.
- If a record component is already an immutable type like String, do you need a compact constructor for copying?No. You only add copying for mutable components. You might still add a compact constructor purely for validation (null/range checks), but no copy is needed for immutable types.
saying these in an interview costs you the question
- Claiming records are automatically deeply immutable with mutable components.
- Writing `this.dates = dates` inside a compact constructor (it takes no explicit assignment; reassign the parameter).
- Forgetting to override the accessor for a mutable component (output leak remains).
- Trying to add non-component instance fields to a record.
- Using a record where inheritance or a mutable builder is actually required.