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 finalfield - A public accessor method with the same name (not
getX()— justx()) - 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
- The compact constructor has no parameter list — the components (
fullName,salary, etc.) are automatically in scope. - Throwing an exception before the body ends prevents the object from being created with invalid data.
- Reassigning a component variable (e.g.,
fullName = fullName.strip()) inside the compact constructor changes what the compiler stores into theprivate finalfield. - 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
- The
SELECT newJPQL syntax tells Hibernate to call the record’s canonical constructor directly, bypassing the JPA entity lifecycle. - The fully qualified class name must be used inside the JPQL string.
- The constructor parameter order must exactly match the JPQL projection column order.
- 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
| Criterion | Use a Record | Use a Plain Class |
|---|---|---|
| Primary purpose | Immutable data carrier / DTO | Entity, service, mutable value type |
| JPA entity | ❌ Cannot be an entity | ✅ Required |
| Inheritance | ❌ Cannot extend or be extended | ✅ Supports full hierarchy |
| Mutability | All fields are final — immutable by design | Fields can be mutable |
| Boilerplate | Near-zero — compiler generates everything | Needs 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 supported | Requires explicit patterns |
| Lombok @Value equivalent | ✅ Records are the standard alternative | Lombok @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.Recordand 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. UseList.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
- Java 21 to 25 LTS Features: What Every Developer Needs to Know
- Jackson with Java Records and Optionals: Serialisation Deep Dive
- Building a REST API with Spring Boot: Complete Beginner’s Guide
- Spring Data JPA: Complete Guide — Repositories, Queries, and Projections
- Java Generics: The Complete Guide
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]”