A record (Java 16, JEP 395) is a special class declaration for
immutable data carriers. It eliminates the boilerplate of writing
constructors, equals(), hashCode(), toString(), and accessor methods
for classes whose sole purpose is to hold data.
// Before records — ~30 lines of boilerplate:
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) { this.x = x; this.y = y; }
public int x() { return x; }
public int y() { return y; }
@Override public boolean equals(Object o) { ... }
@Override public int hashCode() { ... }
@Override public String toString() { ... }
}
// With a record — 1 line:
record Point(int x, int y) {}
Rule of thumb: use a record whenever a class exists purely to group related data; keep domain logic in regular classes.
The identifiers in the record header are called components. For each component the compiler automatically generates:
- A
private finalfield with the same name and type. - A public accessor method with the same name as the component (not
getX()— justx()). - A canonical constructor that accepts all components and assigns them.
equals(),hashCode(), andtoString()based on all components.
record Person(String name, int age) {}
Person p = new Person("Alice", 30);
System.out.println(p.name()); // "Alice" — accessor
System.out.println(p.age()); // 30
System.out.println(p); // Person[name=Alice, age=30]
Person p2 = new Person("Alice", 30);
System.out.println(p.equals(p2)); // true — all components equal
Rule of thumb: record accessors use the component name directly —
point.x() not point.getX().
Record fields are private final, so the reference cannot be
reassigned after construction. However, if a component type is mutable
(e.g., List, array), the object it refers to can still be mutated.
record Scores(List<Integer> values) {}
Scores s = new Scores(new ArrayList<>(List.of(1, 2, 3)));
s.values().add(99); // legal — mutates the list inside the record
System.out.println(s.values()); // [1, 2, 3, 99]
For true deep immutability, wrap mutable components with
Collections.unmodifiableList() or copy them defensively in the
canonical constructor.
Rule of thumb: records give shallow immutability; make components deeply immutable yourself if needed.
The canonical constructor has the same parameter list as the record header. The compiler generates one automatically, but you can override it to add validation or normalisation:
record Range(int min, int max) {
Range { // compact canonical constructor
if (min > max)
throw new IllegalArgumentException("min > max");
// no need to write: this.min = min; this.max = max;
// the compiler appends the assignments automatically
}
}
new Range(1, 10); // ok
new Range(10, 1); // IllegalArgumentException
The compact constructor (no parameter list, no this.x = x assignments)
is the preferred style — the compiler appends the field assignments after
your body.
Rule of thumb: use the compact canonical constructor for validation and normalisation; let the compiler handle the field assignments.
Yes. A record can define alternative constructors that must delegate to
the canonical constructor as their first statement (like this(...)).
record Point(double x, double y) {
Point() { this(0.0, 0.0); } // defaults to origin
Point(double x) { this(x, 0.0); } // y defaults to 0
}
Point origin = new Point(); // (0.0, 0.0)
Point onAxis = new Point(3.0); // (3.0, 0.0)
Rule of thumb: alternative constructors must chain to the canonical constructor — the canonical constructor is always the "source of truth" for field assignment.
Records have several compiler-enforced restrictions:
- Implicitly
final— cannot be subclassed. - Cannot extend any class (implicitly extends
java.lang.Record). - Cannot declare instance fields beyond the components (static fields are allowed).
- Accessor,
equals,hashCode, andtoStringcan be overridden but must stay consistent with component semantics. - Cannot be abstract.
record Pair<A, B>(A first, B second) {} // generic records: fine
// record MyRecord(...) extends SomeClass {} // compile error
// class Child extends Pair {} // compile error — records are final
Rule of thumb: if you need inheritance or mutable state, use a regular class; use a record only when the class is a pure data carrier.
Yes. Records can implement any number of interfaces, including providing custom implementations of the interface's methods.
interface Printable { void print(); }
record Invoice(String id, double amount) implements Printable {
@Override
public void print() {
System.out.printf("Invoice %s: $%.2f%n", id, amount);
}
}
new Invoice("INV-001", 199.99).print(); // Invoice INV-001: $199.99
Records are commonly used with Comparable, Serializable, or custom
domain interfaces.
Rule of thumb: records implement interfaces freely; they just can't extend a class.
| Aspect | Java Record | Lombok @Value / @Data |
|---|---|---|
| Accessor style | x() |
getX() (JavaBean convention) |
| Immutability | Enforced by compiler | @Value enforces, @Data does not |
| Inheritance | Cannot extend or be extended | Normal class inheritance |
| Build tool dependency | None — JDK feature | Requires Lombok on classpath and annotation processor |
| Customisation | Compact constructor | @Builder, @NonNull, etc. |
// Lombok @Value:
@Value public class Point { int x; int y; }
point.getX(); // JavaBean getter
// Java record:
record Point(int x, int y) {}
point.x(); // component accessor
Records are the language-native solution; Lombok is more flexible for
legacy codebases that need getX() style or mutable builders.
Rule of thumb: prefer records for new code on Java 16+; use Lombok when
you need getX() compatibility with frameworks like Jackson (before proper
record support), or when you need mutable builders.
Jackson supports records natively since Jackson 2.12. It serialises using the component accessors and deserialises using the canonical constructor. No extra annotations are needed for simple cases:
record User(String name, int age) {}
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(new User("Alice", 30));
// {"name":"Alice","age":30}
User u = mapper.readValue(json, User.class);
// User[name=Alice, age=30]
For constructor parameter mapping on older Jackson versions or when
component names don't match JSON keys, add
@JsonProperty on the compact constructor parameters.
Rule of thumb: Jackson 2.12+ handles records out of the box; for
older versions add jackson-module-parameter-names and
-parameters compiler flag.
Records are first-class citizens of Java's deconstruction patterns
(Java 21). You can destructure a record's components directly in a
switch or instanceof pattern:
sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double w, double h) implements Shape {}
double area(Shape s) {
return switch (s) {
case Circle(double r) -> Math.PI * r * r;
case Rectangle(double w, double h) -> w * h;
};
}
The components are bound as local variables in the case body without an explicit cast or accessor call.
Rule of thumb: records + sealed interfaces + switch pattern matching work together as a modern alternative to the visitor pattern.
Records support Java serialisation natively when they implement
Serializable. Serialisation is based on the record components rather
than the normal field-based mechanism, which provides better safety
guarantees:
- No
serialVersionUIDis required (though you can add one). - Deserialization always goes through the canonical constructor, so validation in the compact constructor is enforced even during deserialisation — unlike regular classes where deserialization bypasses constructors.
record Point(int x, int y) implements Serializable {}
// Serialise and deserialise:
byte[] bytes = serialize(new Point(1, 2));
Point p = (Point) deserialize(bytes); // canonical constructor called
Rule of thumb: records are safer to serialise than regular classes because the canonical constructor (and its validation) is always called on deserialisation.
Yes. Records support type parameters just like regular classes:
record Pair<A, B>(A first, B second) {}
record Result<T>(T value, String message) {}
Pair<String, Integer> p = new Pair<>("hello", 42);
System.out.println(p.first()); // "hello"
System.out.println(p.second()); // 42
Result<List<String>> r = new Result<>(List.of("a", "b"), "ok");
Type bounds also work: record Bounded<T extends Comparable<T>>(T value) {}.
Rule of thumb: generic records are a clean replacement for generic tuple or wrapper classes — concise and type-safe.
Yes. Records can have static fields, static methods, and instance methods (beyond the generated accessors). They cannot have additional non-static (instance) fields.
record Temperature(double celsius) {
static final double ABSOLUTE_ZERO = -273.15;
static Temperature ofFahrenheit(double f) {
return new Temperature((f - 32) * 5.0 / 9.0);
}
double toFahrenheit() {
return celsius * 9.0 / 5.0 + 32;
}
}
Temperature t = Temperature.ofFahrenheit(98.6);
System.out.println(t.celsius()); // 37.0
System.out.println(t.toFahrenheit()); // 98.6
Rule of thumb: instance methods are fine in records as long as they don't require mutable state — if you need mutable state, use a class.
The compiler-generated equals() and hashCode() are based on all
components, making records correct Map keys and Set elements
immediately without any manual implementation.
record Point(int x, int y) {}
Map<Point, String> labels = new HashMap<>();
labels.put(new Point(0, 0), "origin");
labels.put(new Point(1, 0), "unit-x");
System.out.println(labels.get(new Point(0, 0))); // "origin"
// new Point(0,0).equals(new Point(0,0)) is true — hashCode matches
Compare with a regular class where you must manually implement
equals()/hashCode() or face silent bugs where equal-looking objects
hash differently.
Rule of thumb: records are safe HashMap/HashSet keys out of the
box; regular classes need a careful equals()/hashCode() override.
Yes. Local records (Java 16+) can be declared inside a method, just like local classes. They are useful as lightweight data holders scoped to a single method.
List<String> topEmails(List<User> users) {
record Ranked(User user, int score) {} // local record
return users.stream()
.map(u -> new Ranked(u, computeScore(u)))
.sorted(Comparator.comparingInt(Ranked::score).reversed())
.limit(10)
.map(r -> r.user().email())
.toList();
}
Local records are implicitly static (they don't capture enclosing
instance state).
Rule of thumb: local records are ideal for intermediate pipeline values that need a name but don't deserve a top-level class.
More Modern Java interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.