Since records became final in Java 16, interviewers ask: “What is a record, and is it immutable?” The honest answer is “shallowly”, and the follow-up is where candidates separate themselves: “Then how would you make a record holding a list truly immutable?” They may also ask when not to use a record, to check that you know the boundaries of the feature rather than treating it as a shorter way to write any class.
Records are now everywhere in modern Java code: DTOs in Spring Boot 3 controllers, configuration properties, map keys and messages between services. Knowing exactly what they generate and what they leave to you is practical knowledge, not trivia.
Before you start
You should know how to write a regular Java class with a constructor, getters, equals, hashCode and toString. Understand the equals and hashCode contract and the difference between a final reference and an immutable object. Records require Java 16 or later; examples here compile on Java 17 and 21.
The short answer
A record is a transparent carrier for a fixed set of values. Declaring record Point(int x, int y) gives you private final fields, a canonical constructor, accessors named x() and y(), and equals, hashCode and toString based on all components. Records are implicitly final and cannot extend other classes. They are only shallowly immutable: a List component can still be modified unless you copy it in a compact constructor with List.copyOf.
How it works
This one line:
public record Money(String currency, long cents) {}compiles to roughly the following class:
public final class Money extends java.lang.Record {
private final String currency;
private final long cents;
public Money(String currency, long cents) {
this.currency = currency;
this.cents = cents;
}
public String currency() { return currency; }
public long cents() { return cents; }
@Override public boolean equals(Object o) { /* compares currency and cents */ }
@Override public int hashCode() { /* combines currency and cents */ }
@Override public String toString() { return "Money[currency=" + currency + ", cents=" + cents + "]"; }
}The rules that follow from this design:
- Records can implement interfaces, declare static fields and methods, and add instance methods.
- They cannot declare additional instance fields beyond the components, and cannot extend a class.
- Accessors use the component name, so it is
money.cents(), notgetCents(). equalscompares reference components withObjects.equalsand primitive components with the wrapper’scomparemethod, so twodoublecomponents holdingNaNare equal.
The compact constructor is a special form of the canonical constructor without the parameter list. It runs before the implicit field assignments, and you can reassign the parameters inside it:
public record Money(String currency, long cents) {
public Money {
Objects.requireNonNull(currency, "currency");
if (currency.length() != 3) {
throw new IllegalArgumentException("ISO 4217 code expected: " + currency);
}
currency = currency.toUpperCase(Locale.ROOT); // normalised before assignment
}
}Step-by-step walkthrough
Step 1: Spot the shallow immutability problem
record Order(String id, List<String> items) {}
List<String> items = new ArrayList<>(List.of("book"));
Order order = new Order("o-1", items);
items.add("laptop"); // changes the record's state
order.items().clear(); // so does thisThe items field is final, so it always points to the same list, but the list itself is mutable and shared with the caller.
Step 2: Copy mutable components in the compact constructor
record Order(String id, List<String> items) {
Order {
Objects.requireNonNull(id);
items = List.copyOf(items); // unmodifiable copy, rejects null elements
}
}Because the generated accessor returns the field, and the field now holds an unmodifiable list, callers can no longer change the order from either side.
Step 3: Beware array components
record Hash(byte[] bytes) {}
new Hash(new byte[] {1, 2}).equals(new Hash(new byte[] {1, 2})); // falseArrays use identity equals, so the generated equals compares references. Either override equals, hashCode and toString using Arrays.equals, Arrays.hashCode and Arrays.toString, or store an immutable type such as a List<Byte> or a dedicated value class. Also clone the array in the compact constructor and in an overridden accessor.
Step 4: Add behaviour without breaking transparency
record Money(String currency, long cents) {
Money plus(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("currency mismatch");
}
return new Money(currency, Math.addExact(cents, other.cents));
}
static Money zero(String currency) { return new Money(currency, 0); }
}Records can carry domain logic. Methods that “change” a value return a new record.
Worked scenario
A team migrates its Spring Boot 3 API DTOs and some persistence classes to records. The DTOs work immediately: Jackson 2.12+ deserializes records through their canonical constructor, and Spring’s @ConfigurationProperties binds to records through constructor binding.
Then someone converts a JPA entity:
@Entity
record Customer(@Id Long id, String name) {} // failsStartup fails. JPA providers need a no-argument constructor, non-final fields they can populate and change, and the ability to subclass entities for lazy-loading proxies. Records offer none of these. The same team had also put a List<Address> component in a CustomerView record, and a caching layer later mutated the list it got from a cached instance, corrupting what other requests saw.
The resolution:
@Entity
class Customer {
@Id Long id;
String name;
protected Customer() {} // for JPA
// getters, domain methods
}
record CustomerView(long id, String name, List<AddressView> addresses) {
CustomerView { addresses = List.copyOf(addresses); }
static CustomerView from(Customer c) {
return new CustomerView(c.id, c.name, c.addresses().stream().map(AddressView::from).toList());
}
}The entity stays a class; records are used for the read model at the boundary, with deep immutability enforced by copying and by making AddressView a record too. Hibernate 6.2 and later can map records as @Embeddable value types, which is a valid middle ground for small value objects.
Common mistake
- “Records are immutable.” Only shallowly; mutable components need copies.
- Expecting getX accessors. Frameworks that require JavaBean getters may not recognise
x()without configuration. - Using records as JPA entities. They lack the no-arg constructor, mutability and proxying JPA needs.
- Array components with default equals. Two records holding equal arrays are not equal.
- Putting secrets in records without overriding toString. The generated
toStringprints every component, including passwords and tokens, into logs.
Verify the behavior
import static org.junit.jupiter.api.Assertions.*;
import java.util.*;
import org.junit.jupiter.api.Test;
class RecordTest {
record Shallow(List<String> items) {}
record Deep(List<String> items) { Deep { items = List.copyOf(items); } }
record Hash(byte[] bytes) {}
@Test
void shallowRecordLeaksMutation() {
var src = new ArrayList<>(List.of("a"));
var r = new Shallow(src);
src.add("b");
assertEquals(2, r.items().size());
}
@Test
void compactConstructorCopies() {
var src = new ArrayList<>(List.of("a"));
var r = new Deep(src);
src.add("b");
assertEquals(List.of("a"), r.items());
assertThrows(UnsupportedOperationException.class, () -> r.items().add("c"));
}
@Test
void arrayComponentsCompareByIdentity() {
assertNotEquals(new Hash(new byte[] {1}), new Hash(new byte[] {1}));
}
@Test
void equalComponentsAreEqual() {
assertEquals(new Deep(List.of("a")), new Deep(List.of("a")));
}
}Follow-up questions
Can a record have a no-arg constructor? Yes, as a non-canonical constructor that delegates to the canonical one, such as Money() { this("EUR", 0); }.
Can records be used with sealed interfaces? Yes, and that combination is the basis of algebraic data types in Java: sealed interface Shape permits Circle, Square with record implementations, deconstructed by record patterns in switch on Java 21.
Are records serializable? If they implement Serializable. Deserialization always goes through the canonical constructor, so validation in the compact constructor also protects deserialized instances, unlike ordinary classes.
Can you override an accessor? Yes, as long as it has the same name, return type and is public. Use this to return a copy of an array component.
Interview exercise
Design a record DateRange(LocalDate start, LocalDate end) that rejects invalid ranges and offers a method to check whether a date falls inside it. What validation belongs where, and is the record deeply immutable?
Answer and reasoning
Put validation in a compact constructor so no invalid instance can exist, including deserialized ones:
record DateRange(LocalDate start, LocalDate end) {
DateRange {
Objects.requireNonNull(start, "start");
Objects.requireNonNull(end, "end");
if (end.isBefore(start)) {
throw new IllegalArgumentException("end " + end + " is before start " + start);
}
}
boolean contains(LocalDate d) {
return !d.isBefore(start) && !d.isAfter(end); // inclusive bounds
}
}It is deeply immutable without any copying, because LocalDate is itself immutable. That is the key reasoning: whether a record needs defensive copies depends entirely on the mutability of its component types. State the inclusive or exclusive choice explicitly, since half-open ranges are common in scheduling code.
Continue learning
Practise more with the Java interview questions and the Java MCQs. Related notes: immutable objects and defensive copies and equals and hashCode contracts. The design rationale and rules are in JEP 395: Records.