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 x and y.
  • Accessor methods x() and y() (no get prefix).
  • equals() and hashCode() based on all components.
  • toString() in the form Point[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 also final, 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() (no get), while Lombok generates getSku().
  • Records don’t support inheritance or adding arbitrary fields via a builder pattern as easily as Lombok (@Builder is still more popular for objects with many optional fields).

Note: If your project already uses Lombok extensively and needs @Builder for 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.

javarecordsjava-21spring-bootbest-practices