Java Records Explained
Introduced in Java 14 as a preview feature and finalized in Java 16, Records are a game-changer for writing cleaner, more concise Java code. They provide a compact syntax for declaring classes that are transparent holders for shallowly immutable data.
The Problem with POJOs
Before Records, creating a simple data carrier class (POJO) required a lot of boilerplate:
public class Person {
private final String name;
private final int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() { return name; }
public int getAge() { return age; }
@Override
public boolean equals(Object o) { /* ... */ }
@Override
public int hashCode() { /* ... */ }
@Override
public String toString() { /* ... */ }
}
The Record Solution
With Records, the same class can be defined in a single line:
public record Person(String name, int age) {}
What You Get for Free
When you define a record, the compiler automatically generates:
- Private final fields for all components.
- Public accessor methods (e.g.,
name()andage()). Note: nogetprefix! - Canonical Constructor with the same arguments as the record header.
equals()andhashCode()implementations.toString()method that prints the record's name and its components.
The generated equals compares every component, which is the part that saves you most often. A hand-written equals that someone forgot to update after adding a field is a genuinely difficult bug to find, and with a record it cannot happen.
Customizing Records
You can still customize records if needed. For example, validating data in the constructor:
public record Person(String name, int age) {
// Compact constructor
public Person {
if (age < 0) {
throw new IllegalArgumentException("Age cannot be negative");
}
}
}
The compact constructor has no parameter list and no assignments. You are given the parameters as mutable local variables, you validate or normalise them, and the compiler assigns them to the fields afterwards. That means you can clean up input as well as reject it:
public record Person(String name, int age) {
public Person {
name = name.trim();
}
}
You can add ordinary methods and static factories too:
public record Point(int x, int y) {
public static Point origin() {
return new Point(0, 0);
}
public double distanceTo(Point other) {
return Math.hypot(x - other.x, y - other.y);
}
}
What Records Cannot Do
This is the half most introductions leave out, and it is what decides whether a record fits.
No inheritance. Every record implicitly extends java.lang.Record, and Java has no multiple inheritance, so a record cannot extend anything else. It can implement as many interfaces as you like.
No extra fields. All state lives in the header. You cannot add an instance field to the body, which rules out lazily caching a computed value on the object.
Immutable, but only shallowly. This is the one that bites:
public record Team(String name, List<String> members) {}
var team = new Team("Backend", new ArrayList<>(List.of("Sam")));
team.members().add("Alex"); // compiles, and works
The members reference is final. The list it points at is not. If you want a genuinely immutable record, defend the boundary in the compact constructor:
public record Team(String name, List<String> members) {
public Team {
members = List.copyOf(members);
}
}
Records and Spring Boot
Two places records earn their keep immediately.
Configuration properties. Constructor binding means a record works directly as a typed config holder:
@ConfigurationProperties("app.payment")
public record PaymentProperties(String baseUrl, String apiKey, Duration timeout) {}
Request and response DTOs. A record makes it obvious that an API payload is data and nothing else, and Jackson has understood them for years.
Where records do not fit is as JPA entities. Hibernate needs a no-arg constructor and mutable fields to proxy and manage instances, and a record offers neither. Entities stay as classes. Records are still useful in the persistence layer as constructor projections, which is one way to avoid loading whole entity graphs you did not need:
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("select new com.example.OrderSummary(o.id, o.total) from Order o")
List<OrderSummary> findSummaries();
}
That pattern is worth knowing about for other reasons too, as it sidesteps the N+1 query problem entirely.
Pattern Matching
From Java 21, records can be deconstructed directly in instanceof and switch, which is where the "transparent holder" design pays off:
static String describe(Object shape) {
return switch (shape) {
case Circle(double r) when r > 10 -> "big circle";
case Circle(double r) -> "circle of radius " + r;
case Rect(double w, double h) -> w + " by " + h;
default -> "unknown";
};
}
When to Use Records
- DTOs (Data Transfer Objects): Perfect for carrying data between layers.
- Map Keys: Since
equalsandhashCodeare automatically reliable. - Stream Processing: ideal for temporary data structures in streams.
- Return types for multiple values: far better than returning an array or an oversized
Map.
And when not to: if the type has behaviour rather than data, needs to change after construction, or needs to sit in a class hierarchy, use a class. Records are immutable by default, making them thread-safe and less error-prone. They signal to other developers that "this class is just data."