Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save carefree-ladka/d600a436c246d64df9a56e2dfdc06edd to your computer and use it in GitHub Desktop.

Select an option

Save carefree-ladka/d600a436c246d64df9a56e2dfdc06edd to your computer and use it in GitHub Desktop.
Java Records — The Complete Guide

Java Records — The Complete Guide

Table of Contents


What is a Record?

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.


Basic Syntax

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 objects

Note: record accessor methods use the component name without "get"point.x() not point.getX(). This is an intentional departure from JavaBean conventions.


What the Compiler Generates

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 finalcannot be subclassed
  • Extends java.lang.Record implicitly — cannot extend any other class
  • All component fields are private finalimmutable by default
  • Can implement interfaces
  • Can have static fields and methods
  • Can have instance methods

Constructors in Records

Canonical Constructor

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);   // IllegalArgumentException

Compact Constructor

The 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] — unaffected

Custom Constructor Overloads

You 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 overload

Prefer static factory methods over constructor overloads for named variants — they communicate intent (Point.origin() vs new Point(0, 0)).


Methods in Records

Accessor Methods

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)
}

Adding Custom Methods

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());     // false

Overriding equals, hashCode, toString

The 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"

Record Limitations

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 and Interfaces

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.0

Records and Generics

Records 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));    // false

Nested and Local Records

Records 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.


Records vs Classes vs Lombok

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 records

When to prefer records over Lombok:

  • Java 16+ is available (always prefer records in modern Java)
  • You need pattern matching (instanceof destructuring, switch expressions)
  • 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 in Practice

DTOs and API Responses

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; }
}

Value Objects

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

    // ...
}

Compound Map Keys

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");

Pattern Matching with Records

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;
};

Records in Streams

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 with Sealed Interfaces

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);
};

Serialization

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()); // Laptop

Records 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));
    }
}

Reflection and Records

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 = 30

This reflection support makes records ideal for generic serialization, validation frameworks, and mapping utilities that need to introspect data classes.


Best Practices

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.


Common Mistakes

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 error

Trying to inherit from records

public record Animal(String name) {}
public record Dog(String name, String breed) extends Animal {} // ❌ compile error
// Solution: use interfaces or composition

Ignoring 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment