Java Records: When to Use and When to Avoid
1. Introduction
Before Java 14, if you wanted a class that contained only immutable data, you had to write constructor, getters, equals(), hashCode(), toString() yourself — all repetitive boilerplate. Records solve this exact problem: you declare one line, and Java generates everything automatically.
This article covers the basic syntax of records, how to customize validation, comparison with regular classes and Lombok, then clearly shows when to use records and when they’re not suitable. By the end, you’ll know how to apply records correctly in a real Spring Boot application.
2. What is a Record
A record is a special kind of class used to model immutable data. Declaring a record is equivalent to defining its “components”:
public record Point(int x, int y) {
}
With just this one line, the compiler automatically generates:
- A constructor that takes
xandy. - Accessor methods
x()andy()(nogetprefix). equals()andhashCode()based on all components.toString()in the formPoint[x=1, y=2].
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
System.out.println(p1);
System.out.println(p1.equals(p2));
System.out.println(p1.x());
Point[x=1, y=2]
true
1
Note: Records are automatically
final— you cannot extend a record, and its fields are alsofinal, so they cannot be reassigned after initialization.
3. Components of a Record
3.1. Constructor and Validation
Records have a canonical constructor that’s generated automatically, but you can override it to add validation. This is called a compact constructor — you don’t need to redeclare the parameters:
public record Order(String id, BigDecimal amount) {
public Order {
if (amount == null || amount.signum() < 0) {
throw new IllegalArgumentException("amount must be >= 0");
}
}
}
The line public Order { ... } has no parameter list — it shares the parameters of the record. Validation runs before fields are assigned, so you can prevent invalid data right at object creation time.
new Order("O1", new BigDecimal("-10"));
Exception in thread "main" java.lang.IllegalArgumentException: amount must be >= 0
3.2. Adding Custom Methods
Records can still contain methods, static fields, and static methods like regular classes:
public record Money(String currency, BigDecimal value) {
public Money add(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalStateException("Different currencies");
}
return new Money(currency, value.add(other.value));
}
public static Money zero(String currency) {
return new Money(currency, BigDecimal.ZERO);
}
}
Since value is final, the add() method doesn’t modify the current object — it returns a new Money instance. This is the spirit of immutability that records are designed for.
3.3. Implementing Interfaces
Records can implement interfaces (but cannot extend other classes):
public interface Shape {
double area();
}
public record Rectangle(double width, double height) implements Shape {
@Override
public double area() {
return width * height;
}
}
4. Records in Spring Boot: DTOs and Request/Response
The most common use case for records is DTOs — data transmitted in and out through REST APIs.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
public record CustomerRequest(
@NotBlank String name,
@Email String email
) {
}
@RestController
@RequestMapping("/customers")
public class CustomerController {
@PostMapping
public ResponseEntity<CustomerResponse> create(@Valid @RequestBody CustomerRequest request) {
Customer saved = new Customer(1L, request.name(), request.email());
return ResponseEntity.ok(new CustomerResponse(saved.id(), saved.name()));
}
}
public record CustomerResponse(Long id, String name) {
}
Jackson (preconfigured in Spring Boot) deserializes JSON into records through the canonical constructor, without needing setters. Bean Validation annotations (@NotBlank, @Email) placed directly on record parameters work normally because Spring recognizes these annotations on the constructor.
POST /customers
{"name": "", "email": "a@b.com"}
HTTP 400 Bad Request
Note: If records are used as JPA entities, you’ll run into problems — JPA requires a no-arg constructor and mutable fields for lazy-loading to work. Records don’t support this, so don’t use records as
@Entity.
5. Records vs Regular Classes
| Aspect | Regular Class | Record |
|---|---|---|
| Boilerplate | Must write getter, equals, hashCode, toString yourself |
Auto-generated |
| Mutable | Possible | No (fields always final) |
| Inheritance | Can extend other classes | Cannot extend (implicitly final) |
| Best for | Entities, classes with changing state | DTOs, value objects, immutable data |
Specific comparison with an example of the same value object FullName:
// Old way
public final class FullNameOld {
private final String first;
private final String last;
public FullNameOld(String first, String last) {
this.first = first;
this.last = last;
}
public String getFirst() { return first; }
public String getLast() { return last; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof FullNameOld other)) return false;
return Objects.equals(first, other.first) && Objects.equals(last, other.last);
}
@Override
public int hashCode() {
return Objects.hash(first, last);
}
@Override
public String toString() {
return "FullNameOld[first=" + first + ", last=" + last + "]";
}
}
// With records
public record FullName(String first, String last) {
}
These two classes are behaviorally equivalent, but the record is much shorter and eliminates the risk of writing incorrect equals/hashCode (a classic mistake when coding manually).
6. Records vs Lombok @Value
Many teams used Lombok @Value to reduce boilerplate before records existed. Both approaches solve similar problems, but they differ fundamentally:
// Lombok
@Value
public class ProductDto {
String sku;
BigDecimal price;
}
// Record
public record ProductDto(String sku, BigDecimal price) {
}
Main differences:
- Records are a native Java feature, requiring no additional dependencies or annotation processors.
- Records generate accessors like
sku()(noget), while Lombok generatesgetSku(). - Records don’t support inheritance or adding arbitrary fields via a builder pattern as easily as Lombok (
@Builderis still more popular for objects with many optional fields).
Note: If your project already uses Lombok extensively and needs
@Builderfor multi-field DTOs, you can keep Lombok for those cases. Records are more suitable for simple DTOs with few fields that don’t need builders.
7. When to Use Records
Use records when your data:
- Is inherently immutable: coordinates, money amounts, time ranges, calculation results.
- Is a DTO passed between layers: API request/response, data returned from queries, messages sent over queues.
- Is a value object in your domain model:
Money,EmailAddress,Coordinates— things compared by value, not identity. - Is a temporary result in processing logic: grouping multiple values returned from a method without needing a separate class.
public record ValidationResult(boolean valid, List<String> errors) {
public static ValidationResult ok() {
return new ValidationResult(true, List.of());
}
}
8. When Not to Use Records
8.1. JPA Entities
As mentioned in section 4, JPA requires entities to be mutable, have a no-arg constructor, and support proxying for lazy loading. Records cannot meet these requirements.
// DO NOT do this
@Entity
public record Customer(@Id Long id, String name) {
}
Hibernate will encounter errors or unexpected behavior when trying to proxy records. Use regular classes (or Lombok @Entity + @Getter/@Setter) for entities instead.
8.2. Objects with Frequent State Changes
If your object’s state changes many times during its lifetime (for example, a ShoppingCart that frequently adds/removes items), forcing it into a record requires creating a new object each time — wasteful and unnatural.
// Not practical: creating a new cart each time an item is added
public record ShoppingCart(List<Item> items) {
public ShoppingCart addItem(Item item) {
List<Item> newItems = new ArrayList<>(items);
newItems.add(item);
return new ShoppingCart(newItems);
}
}
In this case, a regular mutable class with List<Item> and a direct-mutation addItem() method is more natural and efficient.
8.3. Requiring Inheritance
Records are implicitly final and cannot extend other classes (only implement interfaces). If your domain model requires a class hierarchy (Employee → Manager, Developer), records aren’t suitable — use regular classes or sealed interfaces/classes combined with records for leaf nodes.
public sealed interface Shape permits Circle, Square {}
public record Circle(double radius) implements Shape {}
public record Square(double side) implements Shape {}
This is a sensible way to use records in hierarchies: records serve as immutable leaf nodes, while sealed interface manages the set of subtypes.
8.4. Complex Serialization Requirements
Records can implement Serializable, but if you need complex custom serialization (versioning, years of backward compatibility), regular classes with finer-grained control are safer.
9. Testing Records
Because equals()/hashCode() are auto-generated based on components, testing is very simple with AssertJ:
class MoneyTest {
@Test
void shouldBeEqualWhenSameValues() {
Money a = new Money("VND", new BigDecimal("1000"));
Money b = new Money("VND", new BigDecimal("1000"));
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
void shouldThrowWhenAddingDifferentCurrency() {
Money vnd = new Money("VND", BigDecimal.TEN);
Money usd = new Money("USD", BigDecimal.ONE);
assertThatThrownBy(() -> vnd.add(usd))
.isInstanceOf(IllegalStateException.class)
.hasMessage("Different currencies");
}
}
assertThat(a).isEqualTo(b) verifies the correct auto-generated equals() behavior — two records with the same component values must be equal, even if they’re different instances in memory.
10. Conclusion
Records are the best tool Java provides for immutable data: less code, fewer errors, clearer intent. Use them for DTOs, value objects, and temporary results. Don’t use them for JPA entities, objects with frequent state changes, or when you need complex class hierarchies through inheritance. Pick the right tool for the job — records don’t completely replace regular classes, they add a more concise option for a specific set of problems.