- What is a Record?
- Basic Syntax
- What the Compiler Generates
- Constructors in Records
- Methods in Records
- Record Limitations
- Records and Interfaces
- Records and Generics
- Nested and Local Records
- Records vs Classes vs Lombok
- Records in Practice
- Serialization
- Reflection and Records
- Best Practices
- Common Mistakes
A record is a special-purpose class introduced in Java 16 (previewed in Java 14–15) designed to model immutable data carriers — objects whose entire purpose is to hold and transport data, with no behavior of their own.
Before records, writing even a simple data class required significant boilerplate:
// ❌ Pre-record — 40+ lines for a simple 3-field data class
public final class Point {
private final int x;
private final int y;
private final int z;
public Point(int x, int y, int z) {
this.x = x;
this.y = y;
this.z = z;
}
public int x() { return x; }
public int y() { return y; }
public int z() { return z; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point p)) return false;
return x == p.x && y == p.y && z == p.z;
}
@Override
public int hashCode() {
return Objects.hash(x, y, z);
}
@Override
public String toString() {
return "Point[x=" + x + ", y=" + y + ", z=" + z + "]";
}
}With records:
// ✅ Record — same result, 1 line
public record Point(int x, int y, int z) {}Records are not a replacement for all classes. They're a precise tool for one job: transparent, immutable data aggregates.
public record RecordName(ComponentType componentName, ...) {
// optional: compact constructor, custom methods, static fields
}
// Simple records
public record Point(int x, int y) {}
public record Person(String name, int age) {}
public record Coordinate(double latitude, double longitude, String label) {}
// With access modifier on components (always public implicitly)
public record Range(int min, int max) {}
// Usage
Point p = new Point(3, 7);
System.out.println(p.x()); // 3
System.out.println(p.y()); // 7
System.out.println(p); // Point[x=3, y=7]
Person alice = new Person("Alice", 30);
System.out.println(alice.name()); // Alice
System.out.println(alice.age()); // 30
System.out.println(alice); // Person[name=Alice, age=30]
// equals and hashCode work out of the box
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
System.out.println(p1.equals(p2)); // true
System.out.println(p1 == p2); // false — different objectsNote: record accessor methods use the component name without "get" —
point.x()notpoint.getX(). This is an intentional departure from JavaBean conventions.
When you declare public record Point(int x, int y) {}, the compiler automatically generates:
// Roughly equivalent to:
public final class Point extends java.lang.Record {
// 1. Private final fields for each component
private final int x;
private final int y;
// 2. Canonical constructor
public Point(int x, int y) {
this.x = x;
this.y = y;
}
// 3. Public accessor methods (named after components — no "get" prefix)
public int x() { return this.x; }
public int y() { return this.y; }
// 4. equals — field-by-field comparison
@Override
public boolean equals(Object o) {
if (!(o instanceof Point other)) return false;
return this.x == other.x && this.y == other.y;
}
// 5. hashCode — based on all components
@Override
public int hashCode() {
return Objects.hash(x, y);
}
// 6. toString — structured, readable
@Override
public String toString() {
return "Point[x=" + x + ", y=" + y + "]";
}
}Key implicit properties of every record:
- The class is
final— cannot be subclassed - Extends
java.lang.Recordimplicitly — cannot extend any other class - All component fields are
private final— immutable by default - Can implement interfaces
- Can have static fields and methods
- Can have instance methods
The canonical constructor matches all record components in declaration order. The compiler generates it automatically, but you can override it to add validation or transformation.
public record Range(int min, int max) {
// Explicit canonical constructor — same signature as components
public Range(int min, int max) {
if (min > max) {
throw new IllegalArgumentException(
"min (%d) must be <= max (%d)".formatted(min, max)
);
}
this.min = min;
this.max = max;
}
}
Range valid = new Range(1, 10); // OK
Range invalid = new Range(10, 1); // IllegalArgumentExceptionThe compact constructor is a cleaner, record-specific syntax — it omits the parameter list and the assignments. The compiler automatically assigns parameters to fields after the body runs.
public record Range(int min, int max) {
// Compact constructor — no parameter list, no explicit assignments
public Range {
if (min > max) {
throw new IllegalArgumentException(
"min (%d) must be <= max (%d)".formatted(min, max)
);
}
// Implicit: this.min = min; this.max = max; — added by compiler
}
}Compact constructors can also normalize/transform input before assignment:
public record Person(String name, String email) {
public Person {
// Normalize — trim and lowercase before storing
name = name.trim();
email = email.trim().toLowerCase();
// Validate after normalizing
if (name.isBlank()) throw new IllegalArgumentException("Name cannot be blank");
if (!email.contains("@")) throw new IllegalArgumentException("Invalid email: " + email);
}
}
Person p = new Person(" Alice ", " ALICE@Example.COM ");
System.out.println(p.name()); // "Alice"
System.out.println(p.email()); // "alice@example.com"Defensive copying in compact constructors for mutable components:
import java.util.List;
public record Classroom(String name, List<String> students) {
public Classroom {
// Defensive copy — prevent external mutation of internal list
students = List.copyOf(students); // also makes it unmodifiable
}
}
List<String> mutableList = new ArrayList<>(List.of("Alice", "Bob"));
Classroom room = new Classroom("Room 101", mutableList);
mutableList.add("Charlie"); // external mutation
System.out.println(room.students()); // [Alice, Bob] — unaffectedYou can add additional constructors to a record, but they must delegate to the canonical constructor as their first statement.
public record Point(double x, double y) {
// Canonical constructor (implicit or explicit)
public Point {
if (Double.isNaN(x) || Double.isNaN(y))
throw new IllegalArgumentException("Coordinates cannot be NaN");
}
// Convenience constructor — origin
public static Point origin() {
return new Point(0.0, 0.0);
}
// Convenience constructor — from polar coordinates
public static Point fromPolar(double radius, double angle) {
return new Point(radius * Math.cos(angle), radius * Math.sin(angle));
}
// Overloaded constructor — must call canonical constructor first
public Point(int x, int y) {
this((double) x, (double) y); // delegates to canonical
}
}
Point p1 = new Point(3.0, 4.0);
Point p2 = Point.origin();
Point p3 = Point.fromPolar(5.0, Math.PI / 4);
Point p4 = new Point(3, 4); // int overloadPrefer static factory methods over constructor overloads for named variants — they communicate intent (
Point.origin()vsnew Point(0, 0)).
Accessor methods are auto-generated for each component. You can override them to add logic, but you cannot change their return type or make them return a mutable copy of the field.
public record Temperature(double value, String unit) {
// Override accessor to add behavior
@Override
public double value() {
return Math.round(value * 100.0) / 100.0; // round to 2 decimal places
}
// Override accessor to return defensive copy
// (needed only if component is mutable — usually avoid mutable components)
}Records can have any number of instance and static methods:
public record Money(BigDecimal amount, String currency) {
// Compact constructor for validation
public Money {
Objects.requireNonNull(amount, "amount");
Objects.requireNonNull(currency, "currency");
if (amount.compareTo(BigDecimal.ZERO) < 0)
throw new IllegalArgumentException("Amount cannot be negative");
amount = amount.setScale(2, RoundingMode.HALF_UP); // normalize scale
}
// Instance methods — return new records (immutability pattern)
public Money add(Money other) {
assertSameCurrency(other);
return new Money(this.amount.add(other.amount), this.currency);
}
public Money subtract(Money other) {
assertSameCurrency(other);
return new Money(this.amount.subtract(other.amount), this.currency);
}
public Money multiply(BigDecimal factor) {
return new Money(this.amount.multiply(factor), this.currency);
}
public boolean isGreaterThan(Money other) {
assertSameCurrency(other);
return this.amount.compareTo(other.amount) > 0;
}
public boolean isZero() {
return amount.compareTo(BigDecimal.ZERO) == 0;
}
// Static factory methods
public static Money of(String amount, String currency) {
return new Money(new BigDecimal(amount), currency);
}
public static Money zero(String currency) {
return new Money(BigDecimal.ZERO, currency);
}
// Private helper
private void assertSameCurrency(Money other) {
if (!this.currency.equals(other.currency))
throw new IllegalArgumentException(
"Currency mismatch: %s vs %s".formatted(this.currency, other.currency)
);
}
@Override
public String toString() {
return amount.toPlainString() + " " + currency;
}
}
Money price = Money.of("19.99", "USD");
Money tax = Money.of("1.60", "USD");
Money total = price.add(tax);
System.out.println(total); // 21.59 USD
System.out.println(total.isZero()); // falseThe compiler-generated implementations are correct for most use cases. Override only when you need custom semantics.
public record CaseInsensitiveKey(String value) {
// Compact constructor — normalize to lowercase
public CaseInsensitiveKey {
Objects.requireNonNull(value, "value");
value = value.toLowerCase();
}
// Custom toString — suppress brackets
@Override
public String toString() {
return value;
}
}
// equals and hashCode are auto-generated based on the normalized lowercase value
CaseInsensitiveKey k1 = new CaseInsensitiveKey("Hello");
CaseInsensitiveKey k2 = new CaseInsensitiveKey("hello");
System.out.println(k1.equals(k2)); // true — both normalized to "hello"Understanding what records cannot do is as important as knowing what they can:
| Limitation | Reason |
|---|---|
| Cannot extend another class | Already implicitly extends java.lang.Record |
| Cannot be abstract | Records are final data holders |
| Cannot be subclassed | Records are implicitly final |
| Components cannot have mutable defaults | All components are private final |
| Cannot declare instance fields outside components | Only static fields allowed |
| Cannot add setter methods | Immutability is the design contract |
| Component accessor names fixed | Always named after the component |
public record Broken(int x) {
public int x; // ❌ compile error — instance fields not allowed
private String extra; // ❌ compile error — same reason
public static int COUNTER = 0; // ✅ static fields OK
public static final int MAX = 100; // ✅ constants OK
public void setX(int x) { // ✅ compiles but defeats the purpose (and won't work on final field)
// this.x = x; // ❌ compile error — field is final
}
}
// ❌ Cannot extend a record
public class ExtendedPoint extends Point { } // compile error
// ❌ Cannot extend another class
public record MyRecord(int x) extends SomeClass { } // compile error
// ✅ Can implement interfaces
public record MyRecord(int x) implements Comparable<MyRecord> { ... }Records can implement any number of interfaces, making them powerful for polymorphic designs:
public interface Shape {
double area();
double perimeter();
default String describe() {
return "%s: area=%.2f, perimeter=%.2f".formatted(
getClass().getSimpleName(), area(), perimeter()
);
}
}
public record Circle(double radius) implements Shape {
public Circle {
if (radius <= 0) throw new IllegalArgumentException("Radius must be positive");
}
@Override public double area() { return Math.PI * radius * radius; }
@Override public double perimeter() { return 2 * Math.PI * radius; }
}
public record Rectangle(double width, double height) implements Shape {
public Rectangle {
if (width <= 0 || height <= 0)
throw new IllegalArgumentException("Dimensions must be positive");
}
@Override public double area() { return width * height; }
@Override public double perimeter() { return 2 * (width + height); }
}
public record Triangle(double a, double b, double c) implements Shape {
public Triangle {
if (a + b <= c || a + c <= b || b + c <= a)
throw new IllegalArgumentException("Invalid triangle sides");
}
@Override public double area() {
double s = (a + b + c) / 2;
return Math.sqrt(s * (s - a) * (s - b) * (s - c)); // Heron's formula
}
@Override public double perimeter() { return a + b + c; }
}
// Polymorphic usage
List<Shape> shapes = List.of(
new Circle(5),
new Rectangle(4, 6),
new Triangle(3, 4, 5)
);
shapes.forEach(s -> System.out.println(s.describe()));
// Circle: area=78.54, perimeter=31.42
// Rectangle: area=24.00, perimeter=20.00
// Triangle: area=6.00, perimeter=12.00
double totalArea = shapes.stream()
.mapToDouble(Shape::area)
.sum();Records also work with Comparable:
public record Version(int major, int minor, int patch) implements Comparable<Version> {
@Override
public int compareTo(Version other) {
int cmp = Integer.compare(this.major, other.major);
if (cmp != 0) return cmp;
cmp = Integer.compare(this.minor, other.minor);
if (cmp != 0) return cmp;
return Integer.compare(this.patch, other.patch);
}
@Override
public String toString() {
return "%d.%d.%d".formatted(major, minor, patch);
}
}
List<Version> versions = new ArrayList<>(List.of(
new Version(2, 0, 0),
new Version(1, 9, 3),
new Version(1, 10, 0),
new Version(1, 9, 10)
));
Collections.sort(versions);
versions.forEach(System.out::println);
// 1.9.3
// 1.9.10
// 1.10.0
// 2.0.0Records fully support generics:
// Generic pair
public record Pair<A, B>(A first, B second) {
public Pair<B, A> swap() {
return new Pair<>(second, first);
}
public static <A, B> Pair<A, B> of(A first, B second) {
return new Pair<>(first, second);
}
}
Pair<String, Integer> p = Pair.of("Alice", 30);
System.out.println(p); // Pair[first=Alice, second=30]
System.out.println(p.swap()); // Pair[first=30, second=Alice]
// Generic result type (like Either / Result)
public record Result<T>(T value, String error) {
public static <T> Result<T> success(T value) {
return new Result<>(value, null);
}
public static <T> Result<T> failure(String error) {
return new Result<>(null, error);
}
public boolean isSuccess() { return error == null; }
public boolean isFailure() { return error != null; }
public T getOrThrow() {
if (isFailure()) throw new RuntimeException(error);
return value;
}
public T getOrDefault(T defaultValue) {
return isSuccess() ? value : defaultValue;
}
}
Result<Integer> ok = Result.success(42);
Result<Integer> err = Result.failure("Not found");
System.out.println(ok.getOrDefault(-1)); // 42
System.out.println(err.getOrDefault(-1)); // -1
// Generic range
public record Range<T extends Comparable<T>>(T min, T max) {
public Range {
if (min.compareTo(max) > 0)
throw new IllegalArgumentException("min must be <= max");
}
public boolean contains(T value) {
return value.compareTo(min) >= 0 && value.compareTo(max) <= 0;
}
}
Range<Integer> ages = new Range<>(18, 65);
Range<LocalDate> dates = new Range<>(LocalDate.of(2020,1,1), LocalDate.of(2025,12,31));
System.out.println(ages.contains(25)); // true
System.out.println(ages.contains(70)); // falseRecords can be declared as nested (inside a class) or local (inside a method):
public class ReportGenerator {
// Static nested record — great for internal DTOs
public record ReportConfig(
String title,
LocalDate from,
LocalDate to,
boolean includeCharts
) {}
// Private nested record — internal implementation detail
private record DataPoint(LocalDate date, double value) {}
public String generate(ReportConfig config) {
// Local record — scoped to this method only
record Summary(double min, double max, double avg) {
String format() {
return "min=%.2f, max=%.2f, avg=%.2f".formatted(min, max, avg);
}
}
List<DataPoint> data = fetchData(config.from(), config.to());
DoubleSummaryStatistics stats = data.stream()
.mapToDouble(DataPoint::value)
.summaryStatistics();
Summary summary = new Summary(stats.getMin(), stats.getMax(), stats.getAverage());
return "%s (%s to %s): %s".formatted(
config.title(), config.from(), config.to(), summary.format()
);
}
private List<DataPoint> fetchData(LocalDate from, LocalDate to) {
// ...
return List.of();
}
}Local records are implicitly
static— they don't capture the enclosing method's local variables (unlike anonymous classes). They're ideal for structuring intermediate results in complex methods.
| Feature | Plain Class | Record | Lombok @Data |
|---|---|---|---|
| Boilerplate | High | None | Minimal (annotations) |
| Immutability | Manual (final) |
Built-in | Optional (@Value) |
equals/hashCode |
Manual | Auto-generated | Auto-generated |
toString |
Manual | Auto-generated | Auto-generated |
| Getters | Manual | Auto-generated | Auto-generated |
| Setters | Manual | ❌ Not generated | Auto-generated |
| Inheritance | ✅ Full | ❌ Cannot extend/subclass | ✅ Full |
| Mutable fields | ✅ | ❌ All final |
✅ |
| Compact constructor | N/A | ✅ | N/A |
| Pattern matching | Limited | ✅ Native support | Limited |
| External dependency | None | None | Lombok JAR |
| Compile-time | Standard | Standard | Annotation processing |
| IDE support | Native | Native | Plugin sometimes needed |
// Lombok @Value — closest equivalent to record
@Value
public class PointLombok {
int x, y;
}
// Record — preferred in Java 16+
public record PointRecord(int x, int y) {}
// Key differences:
// 1. Record accessor: point.x() — Lombok: point.getX()
// 2. Records support pattern matching natively
// 3. Records have compact constructors
// 4. Records are a JVM-level concept (reflected in bytecode)
// 5. No external dependency with recordsWhen to prefer records over Lombok:
- Java 16+ is available (always prefer records in modern Java)
- You need pattern matching (
instanceofdestructuring,switchexpressions) - You want zero external dependencies
- The data is genuinely immutable
When Lombok might still help:
- You need mutable data classes (
@Data) - You need builder pattern (
@Builder) - You need to extend another class
- Legacy Java < 16 codebase
Records are perfect for request/response data transfer objects:
// HTTP request body
public record CreateUserRequest(
String username,
String email,
String password,
LocalDate birthDate
) {
public CreateUserRequest {
Objects.requireNonNull(username, "username");
Objects.requireNonNull(email, "email");
Objects.requireNonNull(password, "password");
if (username.length() < 3)
throw new IllegalArgumentException("Username too short");
if (!email.contains("@"))
throw new IllegalArgumentException("Invalid email");
}
}
// HTTP response body
public record UserResponse(
long id,
String username,
String email,
LocalDate birthDate,
LocalDateTime createdAt
) {}
// Nested response structure
public record PagedResponse<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages
) {
public boolean hasNext() { return page < totalPages - 1; }
public boolean hasPrevious() { return page > 0; }
public boolean isFirst() { return page == 0; }
public boolean isLast() { return page == totalPages - 1; }
}Records excel as Domain-Driven Design value objects:
public record EmailAddress(String value) {
private static final Pattern EMAIL_PATTERN =
Pattern.compile("^[\\w._%+\\-]+@[\\w.\\-]+\\.[a-zA-Z]{2,}$");
public EmailAddress {
Objects.requireNonNull(value, "email");
value = value.trim().toLowerCase();
if (!EMAIL_PATTERN.matcher(value).matches())
throw new IllegalArgumentException("Invalid email: " + value);
}
public String domain() {
return value.substring(value.indexOf('@') + 1);
}
public String localPart() {
return value.substring(0, value.indexOf('@'));
}
}
public record PhoneNumber(String countryCode, String number) {
public PhoneNumber {
Objects.requireNonNull(countryCode, "countryCode");
Objects.requireNonNull(number, "number");
number = number.replaceAll("[\\s\\-()]", ""); // strip formatting
if (!number.matches("\\d{7,15}"))
throw new IllegalArgumentException("Invalid phone number: " + number);
}
public String formatted() {
return "+" + countryCode + " " + number;
}
}
public record Address(
String street,
String city,
String state,
String postalCode,
String country
) {}
// Used in a domain entity
public class Customer {
private final long id;
private final String name;
private final EmailAddress email; // value object
private final PhoneNumber phone; // value object
private final Address address; // value object
// ...
}Records are ideal as composite HashMap keys — equals and hashCode are correctly implemented:
public record CacheKey(String region, String userId, String resourceType) {}
Map<CacheKey, String> cache = new HashMap<>();
cache.put(new CacheKey("us-east", "user123", "profile"), "{ ... }");
cache.put(new CacheKey("eu-west", "user456", "settings"), "{ ... }");
// Lookup works correctly because equals/hashCode cover all fields
String profile = cache.get(new CacheKey("us-east", "user123", "profile")); // found!
// Another common use: cell coordinates
public record Cell(int row, int col) {}
Map<Cell, String> spreadsheet = new HashMap<>();
spreadsheet.put(new Cell(0, 0), "Name");
spreadsheet.put(new Cell(0, 1), "Score");
spreadsheet.put(new Cell(1, 0), "Alice");Java 21 introduced record patterns in instanceof and switch:
// Record pattern in instanceof (Java 21+)
Object obj = new Point(3, 4);
if (obj instanceof Point(int x, int y)) {
System.out.println("Point at " + x + ", " + y); // destructures directly!
}
// Nested record patterns
public record Line(Point start, Point end) {}
Object line = new Line(new Point(0, 0), new Point(5, 5));
if (line instanceof Line(Point(int x1, int y1), Point(int x2, int y2))) {
double length = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
System.out.println("Line length: " + length);
}
// Pattern matching in switch expressions (Java 21+)
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 {}
double area = switch (shape) {
case Circle(double r) -> Math.PI * r * r;
case Rectangle(double w, double h) -> w * h;
case Triangle(double b, double h) -> 0.5 * b * h;
};public record Employee(String name, String department, double salary) {}
List<Employee> employees = List.of(
new Employee("Alice", "Engineering", 95000),
new Employee("Bob", "Marketing", 72000),
new Employee("Charlie", "Engineering", 88000),
new Employee("Diana", "Marketing", 79000),
new Employee("Eve", "Engineering", 102000)
);
// Group by department, get average salary
Map<String, Double> avgSalaryByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.averagingDouble(Employee::salary)
));
// Transform to new record type
public record EmployeeSummary(String name, double salaryK) {}
List<EmployeeSummary> summaries = employees.stream()
.filter(e -> e.salary() > 80000)
.sorted(Comparator.comparingDouble(Employee::salary).reversed())
.map(e -> new EmployeeSummary(e.name(), e.salary() / 1000))
.toList();
// Collect into record-keyed map
Map<String, List<Employee>> byDept = employees.stream()
.collect(Collectors.groupingBy(Employee::department));
// Top earner per department
Map<String, Optional<Employee>> topEarners = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.maxBy(Comparator.comparingDouble(Employee::salary))
));Records and sealed interfaces form a powerful combination for algebraic data types — modeling a fixed set of possible states exhaustively:
// Sealed interface — only these implementations are allowed
public sealed interface PaymentResult
permits PaymentResult.Success, PaymentResult.Failure, PaymentResult.Pending {
record Success(String transactionId, BigDecimal amount, LocalDateTime processedAt)
implements PaymentResult {}
record Failure(String errorCode, String errorMessage, LocalDateTime failedAt)
implements PaymentResult {}
record Pending(String referenceId, LocalDateTime submittedAt)
implements PaymentResult {}
}
// Exhaustive switch — compiler verifies all cases are handled
PaymentResult result = processPayment(request);
String message = switch (result) {
case PaymentResult.Success(var txId, var amt, var time) ->
"Payment of %s processed. Transaction: %s".formatted(amt, txId);
case PaymentResult.Failure(var code, var msg, var time) ->
"Payment failed [%s]: %s".formatted(code, msg);
case PaymentResult.Pending(var ref, var time) ->
"Payment pending. Reference: %s".formatted(ref);
};Records support Java's built-in serialization but with important differences:
// Records are serializable if they implement Serializable
public record UserSnapshot(long id, String name, String email)
implements Serializable {
// serialVersionUID is optional but recommended
@Serial
private static final long serialVersionUID = 1L;
}
// Serialization uses the canonical constructor for deserialization
// (unlike regular classes which bypass constructors)
// This means compact constructor validation runs on deserialization too! ✅Records with JSON (Jackson):
// Jackson 2.12+ supports records natively
public record ProductDTO(String name, double price, int stock) {}
ObjectMapper mapper = new ObjectMapper();
// Serialize
String json = mapper.writeValueAsString(new ProductDTO("Laptop", 999.99, 10));
// {"name":"Laptop","price":999.99,"stock":10}
// Deserialize
ProductDTO product = mapper.readValue(json, ProductDTO.class);
System.out.println(product.name()); // LaptopRecords with Jackson annotations:
public record OrderItem(
@JsonProperty("product_id") String productId,
@JsonProperty("qty") int quantity,
@JsonProperty("unit_price") BigDecimal unitPrice
) {
@JsonIgnore
public BigDecimal total() {
return unitPrice.multiply(BigDecimal.valueOf(quantity));
}
}Java's reflection API has built-in support for records:
public record Person(String name, int age) {}
Class<?> cls = Person.class;
// Check if a class is a record
System.out.println(cls.isRecord()); // true
// Get record components
RecordComponent[] components = cls.getRecordComponents();
for (RecordComponent component : components) {
System.out.println(component.getName() + ": " + component.getType().getSimpleName());
}
// name: String
// age: int
// Access component values via accessor methods
Person person = new Person("Alice", 30);
for (RecordComponent component : components) {
Object value = component.getAccessor().invoke(person);
System.out.println(component.getName() + " = " + value);
}
// name = Alice
// age = 30This reflection support makes records ideal for generic serialization, validation frameworks, and mapping utilities that need to introspect data classes.
1. Use records for data, classes for behavior
// ✅ Record — pure data carrier
public record Coordinate(double lat, double lon) {}
// ✅ Class — has meaningful mutable state and behavior
public class NavigationRoute {
private final List<Coordinate> waypoints = new ArrayList<>();
public void addWaypoint(Coordinate c) { waypoints.add(c); }
public double totalDistance() { /* ... */ return 0; }
}2. Always validate in the compact constructor
public record Percentage(double value) {
public Percentage {
if (value < 0 || value > 100)
throw new IllegalArgumentException("Percentage must be 0–100, got: " + value);
}
}3. Defensive-copy mutable components
// ❌ Leaks internal state
public record Schedule(List<LocalDate> dates) {}
// ✅ Safe — caller cannot mutate the internal list
public record Schedule(List<LocalDate> dates) {
public Schedule {
dates = List.copyOf(dates); // unmodifiable + defensive copy
}
}4. Use static factory methods for named construction
public record Color(int r, int g, int b) {
public static Color red() { return new Color(255, 0, 0); }
public static Color green() { return new Color(0, 255, 0); }
public static Color blue() { return new Color(0, 0, 255); }
public static Color hex(String hex) {
int v = Integer.parseInt(hex.replace("#", ""), 16);
return new Color((v >> 16) & 0xFF, (v >> 8) & 0xFF, v & 0xFF);
}
}
Color red = Color.red();
Color custom = Color.hex("#1A2B3C");5. Return new record instances from transformation methods
public record Point(double x, double y) {
public Point translate(double dx, double dy) { return new Point(x + dx, y + dy); }
public Point scale(double factor) { return new Point(x * factor, y * factor); }
public Point rotate(double angle) {
return new Point(
x * Math.cos(angle) - y * Math.sin(angle),
x * Math.sin(angle) + y * Math.cos(angle)
);
}
}
// Fluent chaining
Point result = new Point(1, 0)
.rotate(Math.PI / 4)
.scale(2)
.translate(1, 1);6. Combine with sealed interfaces for exhaustive domain modeling
Records + sealed interfaces let the compiler enforce that every possible case is handled — no
forgotten else branches, no runtime surprises.
Using records for mutable state — records are intentionally immutable; trying to work around this with mutable collections in components defeats the purpose.
// ❌ Mutable component — defeats immutability
public record Bag(List<String> items) {
public void add(String item) { items.add(item); } // compiles but wrong!
}
// ✅ Immutable — operations return new records
public record Bag(List<String> items) {
public Bag {
items = List.copyOf(items);
}
public Bag add(String item) {
var newItems = new ArrayList<>(items);
newItems.add(item);
return new Bag(newItems);
}
}Expecting JavaBean-style getters — record accessors are name(), not getName(). This can
break frameworks that rely on JavaBean conventions (older Jackson, some ORMs).
Person p = new Person("Alice", 30);
p.name(); // ✅ record accessor
p.getName(); // ❌ does not exist — compile errorTrying to inherit from records
public record Animal(String name) {}
public record Dog(String name, String breed) extends Animal {} // ❌ compile error
// Solution: use interfaces or compositionIgnoring that toString reveals all fields — records print all components in toString,
including sensitive data like passwords or tokens. Override when needed.
// ❌ toString exposes password!
public record LoginRequest(String username, String password) {}
// LoginRequest[username=alice, password=s3cr3t]
// ✅ Override to mask sensitive fields
public record LoginRequest(String username, String password) {
@Override
public String toString() {
return "LoginRequest[username=" + username + ", password=***]";
}
}Using records as JPA entities — JPA requires a no-arg constructor, mutable fields, and the ability to subclass proxy objects — all of which conflict with records.
// ❌ Records don't work as JPA/Hibernate entities
@Entity
public record UserEntity(Long id, String name) {} // won't work with JPA
// ✅ Use a regular class for JPA entities
// ✅ Use a record as a DTO to transfer data to/from the entity layer