Java Records: The Complete Guide to Immutable Data Classes

Before Java 16, writing a simple data-carrier class required a constructor, private fields, getters, equals(), hashCode(), and toString() — anywhere from 30 to 60 lines of boilerplate for a handful of fields. Java Records eliminate all of that. A record is a restricted class that is transparently immutable: you declare the fields once, and the compiler generates everything else.

This guide covers every aspect of Java Records you will encounter in a production Spring Boot or Hibernate codebase: declaration syntax, compact constructors, custom accessors, serialisation with Jackson, usage with Hibernate, and the important limitations you need to understand before choosing a record over a plain class.


1. What Is a Java Record?

A record is a special-purpose class declaration introduced in Java 16 (JEP 395) after two preview rounds (Java 14 and 15). Its single purpose is to model immutable data. Every component declared in the record header becomes:

  • A private final field
  • A public accessor method with the same name (not getX() — just x())
  • A parameter in the canonical constructor

The compiler also synthesises equals(), hashCode(), and toString() based on all components. You get a correct, complete data class in one line.

// A complete, immutable data class in one line
public record Point(double x, double y) { }

That single declaration is equivalent to roughly 40 lines of a hand-written class with private final fields, a constructor, two getters, equals(), hashCode(), and toString().


2. Declaration Syntax

The full syntax of a record declaration is straightforward. The record header lists all components — these are the record’s fields.

package com.example.model;

import java.time.LocalDate;

// record keyword replaces 'class'
// Components listed in parentheses: (type name, type name, ...)
public record Employee(
        long       employeeId,        // component 1: unique identifier
        String     fullName,          // component 2: display name
        String     department,        // component 3: team or department
        LocalDate  hireDate,          // component 4: start date
        double     salary             // component 5: annual salary
) { }
// No body needed for a basic record — everything is generated.

The generated code is equivalent to:

// What the compiler effectively generates (simplified)
public final class Employee {
    private final long      employeeId;
    private final String    fullName;
    private final String    department;
    private final LocalDate hireDate;
    private final double    salary;

    // Canonical constructor
    public Employee(long employeeId, String fullName, String department,
                    LocalDate hireDate, double salary) {
        this.employeeId = employeeId;
        this.fullName   = fullName;
        this.department = department;
        this.hireDate   = hireDate;
        this.salary     = salary;
    }

    // Accessor methods — note: no 'get' prefix
    public long      employeeId()  { return employeeId; }
    public String    fullName()    { return fullName; }
    public String    department()  { return department; }
    public LocalDate hireDate()    { return hireDate; }
    public double    salary()      { return salary; }

    // Correct implementations based on all components
    @Override public boolean equals(Object o) { /* all-field comparison */ }
    @Override public int     hashCode()       { /* all-field hash */ }
    @Override public String  toString()       { /* Employee[employeeId=1, ...] */ }
}

Key structural rules to note: a record class is implicitly final (cannot be subclassed), all component fields are implicitly private and final, and records cannot declare instance fields outside the header.


3. The Compact Constructor: Adding Validation

The most common customisation is adding validation. The compact constructor lets you intercept the canonical constructor without re-listing the parameters. The compiler assigns each component to its field after the compact constructor body runs.

public record Employee(
        long      employeeId,
        String    fullName,
        String    department,
        LocalDate hireDate,
        double    salary
) {
    // Compact constructor — no parameter list needed; components are implicitly available.
    // Field assignments happen automatically AFTER this block executes.
    public Employee {
        // Validate required strings
        if (fullName == null || fullName.isBlank()) {
            throw new IllegalArgumentException("fullName must not be blank");
        }
        if (department == null || department.isBlank()) {
            throw new IllegalArgumentException("department must not be blank");
        }
        // Validate numeric range
        if (salary < 0) {
            throw new IllegalArgumentException("salary must be non-negative, got: " + salary);
        }
        // Normalise data — reassigning the component variable here changes the stored value
        fullName   = fullName.strip();          // trim surrounding whitespace
        department = department.strip().toUpperCase(); // normalise to uppercase
    }
}

How the Code Works

  1. The compact constructor has no parameter list — the components (fullName, salary, etc.) are automatically in scope.
  2. Throwing an exception before the body ends prevents the object from being created with invalid data.
  3. Reassigning a component variable (e.g., fullName = fullName.strip()) inside the compact constructor changes what the compiler stores into the private final field.
  4. The field assignments (this.fullName = fullName) are injected by the compiler at the end of the compact constructor — you do not write them yourself.

4. Custom Accessors and Instance Methods

You can override the generated accessor for any component, and you can add arbitrary instance methods (and static methods) to the record body. Records cannot add instance fields, but they can hold computed, derived values.

public record MonetaryAmount(
        double   value,
        String   currencyCode   // e.g. "USD", "EUR"
) {
    // Override the generated accessor to return a rounded display value.
    // The underlying field still holds the full precision double.
    @Override
    public double value() {
        return Math.round(value * 100.0) / 100.0;  // round to 2 decimal places
    }

    // Custom derived method — does not break immutability
    public MonetaryAmount add(MonetaryAmount other) {
        if (!this.currencyCode.equals(other.currencyCode)) {
            throw new IllegalArgumentException(
                "Cannot add amounts in different currencies: "
                + this.currencyCode + " vs " + other.currencyCode);
        }
        // Records are immutable — return a new instance rather than mutating
        return new MonetaryAmount(this.value + other.value, this.currencyCode);
    }

    // Static factory method — a useful pattern to control creation
    public static MonetaryAmount ofUsd(double amount) {
        return new MonetaryAmount(amount, "USD");
    }

    // Formatted display string
    public String display() {
        return currencyCode + " " + String.format("%.2f", value);
    }
}
// Sample usage
MonetaryAmount price    = MonetaryAmount.ofUsd(19.999);
MonetaryAmount shipping = MonetaryAmount.ofUsd(4.50);
MonetaryAmount total    = price.add(shipping);

System.out.println(price.display());    // USD 20.00   (rounded accessor)
System.out.println(shipping.display()); // USD 4.50
System.out.println(total.display());    // USD 24.50
System.out.println(total);             // MonetaryAmount[value=24.499, currencyCode=USD]

5. Records and Interfaces

Records can implement interfaces. This is the mechanism used to make records participate in sealed-class hierarchies (Java 17+) and to define protocol contracts for data types.

// A common interface for all API responses
public interface ApiResponse {
    boolean success();
    String  message();
}

// A record implementing the interface — compact and type-safe
public record SuccessResponse(
        String  message,
        Object  data          // the actual response payload
) implements ApiResponse {

    // The 'message' accessor is already generated by the record.
    // We only need to implement the 'success' method.
    @Override
    public boolean success() {
        return true;          // always true for a SuccessResponse
    }
}

public record ErrorResponse(
        String  message,
        int     errorCode
) implements ApiResponse {

    @Override
    public boolean success() {
        return false;         // always false for an ErrorResponse
    }
}

6. Java Records with Jackson (JSON Serialisation)

Jackson 2.12+ supports Java Records natively. Records serialise and deserialise without any additional configuration in a Spring Boot 3 application because Spring Boot autoconfigures Jackson automatically. The only caveat: Jackson uses the accessor method names (without get) as JSON property names.

import com.fasterxml.jackson.annotation.JsonProperty;

// This record will be serialised to/from JSON by Jackson in a REST controller
public record ProductDto(
        Long   id,

        // @JsonProperty renames the JSON key when it must differ from the accessor name
        @JsonProperty("product_name")
        String productName,

        double price,

        // @JsonIgnore excludes a field from both serialisation and deserialisation
        @com.fasterxml.jackson.annotation.JsonIgnore
        String internalSku
) { }
// Serialisation — record to JSON
ObjectMapper mapper = new ObjectMapper();
ProductDto product  = new ProductDto(1L, "Wireless Keyboard", 49.99, "SKU-WK-001");

String json = mapper.writeValueAsString(product);
// Output: {"id":1,"product_name":"Wireless Keyboard","price":49.99}
// Note: internalSku is excluded by @JsonIgnore

// Deserialisation — JSON to record
String incoming = "{"id":2,"product_name":"USB Hub","price":19.99}";
ProductDto parsed = mapper.readValue(incoming, ProductDto.class);

System.out.println(parsed.productName()); // USB Hub
System.out.println(parsed.price());       // 19.99
System.out.println(parsed.internalSku()); // null (not in JSON)

In a Spring Boot REST controller, no extra configuration is needed — returning a record directly from a @RestController method serialises it to JSON automatically.

@RestController
@RequestMapping("/api/products")
public class ProductController {

    @GetMapping("/{id}")
    public ProductDto getProduct(@PathVariable Long id) {
        // Spring Boot + Jackson serialises the record to JSON automatically
        return new ProductDto(id, "Wireless Keyboard", 49.99, "SKU-WK-001");
        // Response body: {"id":1,"product_name":"Wireless Keyboard","price":49.99}
    }
}

7. Java Records with Hibernate / JPA

This is the most important limitation to understand: Java Records cannot be JPA entities. The JPA specification requires entities to be non-final, have a no-argument constructor, and have mutable state so the persistence provider can set field values during hydration. Records violate all three requirements.

Records have two valid roles in a Hibernate project: as constructor-expression projections in JPQL queries, and as DTOs returned from repositories.

// EmployeeEntity.java — the JPA entity (must be a regular class, not a record)
@Entity
@Table(name = "employees")
public class EmployeeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long   id;
    private String fullName;
    private String department;
    private double salary;

    // JPA requires a no-arg constructor
    protected EmployeeEntity() { }

    public EmployeeEntity(String fullName, String department, double salary) {
        this.fullName   = fullName;
        this.department = department;
        this.salary     = salary;
    }

    // Getters...
    public Long   getId()         { return id; }
    public String getFullName()   { return fullName; }
    public String getDepartment() { return department; }
    public double getSalary()     { return salary; }
}
// EmployeeSummary.java — a record used as a JPQL projection DTO
// Records work perfectly here because Hibernate only needs to CONSTRUCT them, not modify them.
public record EmployeeSummary(
        String fullName,
        String department,
        double salary
) { }
// Using the record in a JPQL constructor expression
// Hibernate calls new EmployeeSummary(e.fullName, e.department, e.salary) for each row
@Repository
public class EmployeeRepository {

    @PersistenceContext
    private EntityManager entityManager;

    public List<EmployeeSummary> findSeniorEmployeeSummaries(double minimumSalary) {
        String jpql = """
                SELECT new com.example.dto.EmployeeSummary(
                           e.fullName, e.department, e.salary)
                FROM   EmployeeEntity e
                WHERE  e.salary >= :minimumSalary
                ORDER  BY e.salary DESC
                """;
        return entityManager
                .createQuery(jpql, EmployeeSummary.class)
                .setParameter("minimumSalary", minimumSalary)
                .getResultList();
    }
}

How the Code Works

  1. The SELECT new JPQL syntax tells Hibernate to call the record’s canonical constructor directly, bypassing the JPA entity lifecycle.
  2. The fully qualified class name must be used inside the JPQL string.
  3. The constructor parameter order must exactly match the JPQL projection column order.
  4. Because the record is immutable, Hibernate cannot inadvertently modify the projected data, which is the desired behaviour for a read-only DTO.

8. Records with Bean Validation

Bean Validation annotations (@NotNull, @Size, @Email, etc.) can be applied to record components and work correctly when used with @Valid in a Spring MVC controller or when validated programmatically via a Validator.

import jakarta.validation.constraints.*;

// Request DTO used in a Spring Boot REST controller
// Validation annotations go on the component declarations
public record CreateUserRequest(

        @NotBlank(message = "Username must not be blank")
        @Size(min = 3, max = 30, message = "Username must be 3–30 characters")
        String username,

        @NotBlank(message = "Email must not be blank")
        @Email(message = "Email must be a valid address")
        String email,

        @NotNull(message = "Age must be provided")
        @Min(value = 18, message = "User must be at least 18 years old")
        @Max(value = 120, message = "Age seems unrealistic")
        Integer age
) { }
@RestController
@RequestMapping("/api/users")
public class UserController {

    // @Valid triggers Bean Validation on the incoming record.
    // If any constraint is violated, Spring returns 400 Bad Request automatically.
    @PostMapping
    public ResponseEntity<String> createUser(@Valid @RequestBody CreateUserRequest request) {
        // At this point request is guaranteed to be valid
        return ResponseEntity.status(HttpStatus.CREATED)
                .body("Created user: " + request.username());
    }
}

9. Records vs. Plain Classes: When to Use Each

CriterionUse a RecordUse a Plain Class
Primary purposeImmutable data carrier / DTOEntity, service, mutable value type
JPA entity❌ Cannot be an entity✅ Required
Inheritance❌ Cannot extend or be extended✅ Supports full hierarchy
MutabilityAll fields are final — immutable by designFields can be mutable
BoilerplateNear-zero — compiler generates everythingNeeds equals/hashCode/toString
Jackson serialisation✅ Native support (Jackson 2.12+)✅ Requires getters or annotations
Bean Validation✅ Annotations on components work✅ Annotations on fields work
Pattern matching (Java 21)✅ Deconstruction patterns supportedRequires explicit patterns
Lombok @Value equivalent✅ Records are the standard alternativeLombok @Value or manual

10. Records and Pattern Matching (Java 21)

Java 21 adds record patterns, which allow you to deconstruct a record’s components directly inside an instanceof or switch expression. This makes records especially powerful in domain-modelling code that uses sealed interfaces.

// Define a sealed interface with record implementations
sealed interface Shape permits Circle, Rectangle, Triangle { }

record Circle    (double radius)                     implements Shape { }
record Rectangle (double width,  double height)      implements Shape { }
record Triangle  (double base,   double height)      implements Shape { }

public class ShapeCalculator {

    // Pattern matching in a switch expression — Java 21+
    public static double area(Shape shape) {
        return switch (shape) {
            // Record pattern: deconstructs the record directly in the case clause
            case Circle    c          -> Math.PI * c.radius() * c.radius();
            case Rectangle(var w, var h) -> w * h;      // deconstruction pattern
            case Triangle (var b, var h) -> 0.5 * b * h;
        };
    }

    public static void main(String[] args) {
        Shape circle    = new Circle(5.0);
        Shape rectangle = new Rectangle(4.0, 6.0);
        Shape triangle  = new Triangle(3.0, 8.0);

        System.out.printf("Circle area:    %.2f%n", area(circle));    // 78.54
        System.out.printf("Rectangle area: %.2f%n", area(rectangle)); // 24.00
        System.out.printf("Triangle area:  %.2f%n", area(triangle));  // 12.00
    }
}

11. Limitations and Gotchas

Records are purpose-built for immutable data carriers. Step outside that purpose and you will hit hard constraints:

  • Cannot extend a class. Records implicitly extend java.lang.Record and the JVM does not allow multiple inheritance of classes. Records can implement interfaces.
  • Cannot be subclassed. Records are implicitly final. You cannot create a class that extends a record.
  • Cannot declare instance fields outside the header. Any field you declare in the body must be static.
  • Cannot be JPA entities. JPA/Hibernate requires a no-arg constructor and mutable fields.
  • No setters by design. Records are immutable. To “change” a record, create a new one. The with-style builder pattern (common in Kotlin data classes) must be written by hand.
  • Shallow immutability. If a component is a mutable type (e.g., List<String>), the record reference is final but the list contents can still be mutated. Use List.copyOf() in the compact constructor to enforce deep immutability.
// Demonstrating the shallow-immutability pitfall and its fix
public record ImmutableOrder(
        long         orderId,
        List<String> lineItems   // mutable list — dangerous without defensive copy
) {
    // Compact constructor: replace the incoming list with an unmodifiable copy
    public ImmutableOrder {
        // List.copyOf throws NullPointerException if lineItems is null
        lineItems = List.copyOf(lineItems); // now truly immutable
    }
}

// Test
List<String> items = new ArrayList<>(List.of("Widget", "Gadget"));
ImmutableOrder order = new ImmutableOrder(42L, items);

items.add("Doohickey");                       // mutate the original list
System.out.println(order.lineItems().size()); // still 2 — defensive copy worked
// order.lineItems().add("X");               // throws UnsupportedOperationException

See Also

Conclusion

Java Records are the right tool whenever you need a transparent, immutable data carrier: REST request/response DTOs, JPQL projection types, value objects in domain-driven design, event payloads, and configuration snapshots. They eliminate boilerplate, make equals() and hashCode() correct by default, and integrate naturally with Jackson, Bean Validation, and Java 21 pattern matching. The constraints — no entity mapping, no subclassing, no mutable fields — are not shortcomings; they are precisely what makes records safe and predictable in multi-threaded environments. Use a regular class when you need JPA persistence or mutable state; use a record for everything else that is primarily data.


Frequently Asked Questions

Can a Java Record be used as a Hibernate entity?

No. JPA entities must be non-final, have a no-argument constructor, and allow the persistence provider to set field values through reflection. Records are implicitly final, have no public no-arg constructor, and store all values in immutable fields. Use a plain class for entities and a record for DTOs and projections.

What is the difference between a Java Record and a Lombok @Value class?

Both produce immutable data classes with generated equals(), hashCode(), and toString(). The differences: Records are a first-class JVM construct (no annotation processor required, supported by all modern tools); Lombok @Value generates getter methods named getX() while records use x(); Lombok classes can extend other classes while records cannot. For new code targeting Java 16+, prefer records over Lombok @Value.

Do Java Records work with Spring Boot’s @ConfigurationProperties?

Yes, from Spring Boot 2.6+. Annotate the record with @ConfigurationProperties(prefix = "app") and add @EnableConfigurationProperties on the configuration class. Spring Boot will bind properties to the record’s components through the canonical constructor.

Can a Java Record implement a sealed interface?

Yes. Records commonly serve as the leaf cases of a sealed interface hierarchy. Sealed interfaces list permitted record subtypes, and Java 21 pattern matching in switch can then deconstruct each record variant — a powerful combination for modelling algebraic data types in Java.

Are Java Records thread-safe?

Records are thread-safe with respect to their components because all fields are private final and written exactly once during construction. However, if a component holds a mutable object (e.g., a List), the contents of that object are not protected. Use defensive copies in the compact constructor (e.g., List.copyOf()) to achieve full immutability.

AI Prompts for Java Records

Copy-paste these prompts into Claude, ChatGPT, or Gemini to accelerate your work with Java Records:

  • “Convert this Java class into a record, adding a compact constructor that validates all non-null fields and makes any List components unmodifiable: [paste class]”
  • “Write a JPQL SELECT NEW projection using this record as the target type against this entity: [paste record and entity]”
  • “Generate a sealed interface with three record implementations and a Java 21 switch expression that handles all cases with pattern matching.”
  • “Show how to use @ConfigurationProperties with a Java Record in Spring Boot 3, binding these properties: [paste application.properties section]”
  • “Add Jackson annotations to this record to rename two fields in the JSON output and exclude one field from serialisation: [paste record]”

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.