Java Record

1. What is a Record?

Java developers have written the same boilerplate code millions of times: a class with private fields, a constructor, getters, equals(), hashCode(), and toString(). For a simple class that carries two fields, you end up writing 40+ lines of code where only two lines — the field declarations — carry actual meaning. The rest is ceremony.

Records, introduced as a preview feature in Java 14 and finalized in Java 16, solve this problem. A record is a special kind of class designed to be a transparent carrier of immutable data. You declare the data, and the compiler generates everything else.

Think of a record as a named tuple with built-in structure. You tell Java “I need a type that holds these values,” and Java handles the plumbing — constructor, accessors, equality, hashing, and a human-readable string representation.

The problem records solve:

Before records, a simple class to hold a 2D point looked like this:

// Before records: 40+ lines for a simple data carrier
public class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int getX() {
        return x;
    }

    public int getY() {
        return y;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Point point = (Point) o;
        return x == point.x && y == point.y;
    }

    @Override
    public int hashCode() {
        return Objects.hash(x, y);
    }

    @Override
    public String toString() {
        return "Point[x=" + x + ", y=" + y + "]";
    }
}

With records, the entire class collapses to a single line:

// After records: 1 line, same behavior
public record Point(int x, int y) { }

That single line gives you everything the 40-line class had: a constructor, accessor methods x() and y(), properly implemented equals(), hashCode(), and toString(). The fields are private and final. The class is implicitly final. No boilerplate, no bugs from forgetting to update equals() when you add a field.

What the compiler generates for you:

  • A canonical constructor that takes all components as parameters and assigns them to fields
  • Accessor methods for each component (named after the component, not with a get prefix)
  • equals() that compares all components for equality
  • hashCode() computed from all components
  • toString() that prints the record name and all component values
public class RecordBasics {
    // A record with two components
    record Point(int x, int y) { }

    public static void main(String[] args) {
        Point p1 = new Point(3, 7);
        Point p2 = new Point(3, 7);
        Point p3 = new Point(1, 2);

        // Accessor methods -- note: x() not getX()
        System.out.println("x = " + p1.x());       // Output: x = 3
        System.out.println("y = " + p1.y());       // Output: y = 7

        // toString() includes component names and values
        System.out.println(p1);                     // Output: Point[x=3, y=7]

        // equals() compares all components
        System.out.println(p1.equals(p2));          // Output: true
        System.out.println(p1.equals(p3));          // Output: false

        // hashCode() is consistent with equals()
        System.out.println(p1.hashCode() == p2.hashCode());  // Output: true
    }
}

2. Record Syntax

A record declaration consists of a name and a component list in parentheses. The components define both the fields and the constructor parameters.

// Basic syntax: record Name(Type1 component1, Type2 component2, ...) { }

// A record with no components (legal but uncommon)
record Empty() { }

// A record with one component
record Wrapper(String value) { }

// A record with multiple components
record Person(String name, int age, String email) { }

// A record can have a body with additional members
record Temperature(double celsius) {
    // Static fields are allowed
    static final double ABSOLUTE_ZERO = -273.15;

    // Instance methods are allowed
    double fahrenheit() {
        return celsius * 9.0 / 5.0 + 32;
    }

    // Static methods are allowed
    static Temperature fromFahrenheit(double f) {
        return new Temperature((f - 32) * 5.0 / 9.0);
    }
}

2.1 Component Accessors

Unlike traditional JavaBean getters that use the get prefix (getName(), getAge()), record accessor methods are named directly after the component. A record Person(String name, int age) has accessors name() and age(), not getName() and getAge().

This is a deliberate design choice. Records are not JavaBeans. They are transparent data carriers, and their accessor names reflect this transparency — the accessor name matches the component name exactly.

record Employee(String name, String department, double salary) { }

public class AccessorDemo {
    public static void main(String[] args) {
        Employee emp = new Employee("Alice", "Engineering", 95000.0);

        // Correct: accessor name matches the component name
        String name = emp.name();             // "Alice"
        String dept = emp.department();       // "Engineering"
        double salary = emp.salary();         // 95000.0

        // Wrong: there is no getName(), getDepartment(), getSalary()
        // emp.getName();    // COMPILE ERROR
        // emp.getSalary();  // COMPILE ERROR

        System.out.println(name + " in " + dept + " earns $" + salary);
        // Output: Alice in Engineering earns $95000.0
    }
}

3. What You Get for Free

When you declare a record, the compiler generates several members automatically. Understanding exactly what gets generated helps you reason about record behavior and decide when to override the defaults.

For the record declaration record RGB(int red, int green, int blue) { }, here is the equivalent class the compiler conceptually generates:

// What the compiler generates for: record RGB(int red, int green, int blue) { }
// This is the conceptual equivalent -- you never write this yourself.

public final class RGB extends java.lang.Record {

    private final int red;
    private final int green;
    private final int blue;

    // Canonical constructor
    public RGB(int red, int green, int blue) {
        this.red = red;
        this.green = green;
        this.blue = blue;
    }

    // Accessor methods
    public int red()   { return this.red; }
    public int green() { return this.green; }
    public int blue()  { return this.blue; }

    // equals() -- two RGB values are equal if all components match
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof RGB)) return false;
        RGB rgb = (RGB) o;
        return red == rgb.red && green == rgb.green && blue == rgb.blue;
    }

    // hashCode() -- computed from all components
    @Override
    public int hashCode() {
        return Objects.hash(red, green, blue);
    }

    // toString() -- includes record name and component values
    @Override
    public String toString() {
        return "RGB[red=" + red + ", green=" + green + ", blue=" + blue + "]";
    }
}

Let us verify this behavior with a concrete example:

import java.util.HashSet;
import java.util.Set;

public class FreeFeatures {
    record RGB(int red, int green, int blue) { }

    public static void main(String[] args) {
        RGB crimson  = new RGB(220, 20, 60);
        RGB crimson2 = new RGB(220, 20, 60);
        RGB navy     = new RGB(0, 0, 128);

        // 1. toString() -- human-readable by default
        System.out.println(crimson);
        // Output: RGB[red=220, green=20, blue=60]

        // 2. equals() -- structural equality, not reference equality
        System.out.println(crimson.equals(crimson2));  // Output: true
        System.out.println(crimson.equals(navy));      // Output: false
        System.out.println(crimson == crimson2);       // Output: false (different objects)

        // 3. hashCode() -- consistent with equals, works in hash-based collections
        Set colors = new HashSet<>();
        colors.add(crimson);
        colors.add(crimson2);  // duplicate -- not added
        colors.add(navy);
        System.out.println("Set size: " + colors.size());  // Output: Set size: 2

        // 4. Accessor methods
        System.out.printf("R=%d, G=%d, B=%d%n", crimson.red(), crimson.green(), crimson.blue());
        // Output: R=220, G=20, B=60
    }
}

4. Compact Constructor

A compact constructor is a special constructor syntax available only in records. It lets you add validation, normalization, or defensive copying logic without writing the boilerplate assignment statements. The compiler inserts the this.field = field; assignments automatically at the end of the compact constructor.

The compact constructor has no parameter list — it implicitly receives the same parameters as the canonical constructor. You work directly with the parameter names, and the compiler assigns them to the corresponding fields after your code runs.

public class CompactConstructorDemo {

    // Validation: reject invalid values
    record Age(int value) {
        Age {  // compact constructor -- no parentheses
            if (value < 0 || value > 150) {
                throw new IllegalArgumentException("Age must be between 0 and 150, got: " + value);
            }
            // No need to write: this.value = value;
            // The compiler adds it automatically after this block
        }
    }

    // Normalization: convert data to a canonical form
    record Email(String address) {
        Email {
            // Trim whitespace and convert to lowercase
            address = address.strip().toLowerCase();
            // The compiler assigns: this.address = address;
            // So the normalized value gets stored
        }
    }

    // Multiple validations and normalization combined
    record Username(String value) {
        Username {
            if (value == null || value.isBlank()) {
                throw new IllegalArgumentException("Username cannot be null or blank");
            }
            value = value.strip().toLowerCase();
            if (value.length() < 3 || value.length() > 30) {
                throw new IllegalArgumentException(
                    "Username must be 3-30 characters, got: " + value.length());
            }
            if (!value.matches("^[a-z0-9_]+$")) {
                throw new IllegalArgumentException(
                    "Username can only contain lowercase letters, digits, and underscores");
            }
        }
    }

    public static void main(String[] args) {
        // Validation in action
        Age validAge = new Age(25);
        System.out.println(validAge);          // Output: Age[value=25]

        try {
            Age invalid = new Age(-5);
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage()); // Output: Age must be between 0 and 150, got: -5
        }

        // Normalization in action
        Email email = new Email("  Alice@Example.COM  ");
        System.out.println(email.address());   // Output: alice@example.com

        // Combined validation and normalization
        Username user = new Username("  John_Doe42  ");
        System.out.println(user.value());      // Output: john_doe42
    }
}

4.1 Defensive Copying in Compact Constructors

Records guarantee that their fields are final, but final does not mean deeply immutable. If a record component is a mutable object (like a List or Date), the caller could modify the original reference after construction, breaking the record’s data integrity. Defensive copying solves this by storing a copy of the mutable data.

import java.util.List;
import java.util.Collections;

public class DefensiveCopyDemo {

    // BAD: mutable list can be modified from outside
    record TagsBad(String name, List tags) { }

    // GOOD: defensive copy in compact constructor
    record TagsGood(String name, List tags) {
        TagsGood {
            // Create an unmodifiable copy so the caller cannot mutate our internal state
            tags = List.copyOf(tags);
        }
    }

    public static void main(String[] args) {
        // Demonstrating the problem with mutable components
        var mutableList = new java.util.ArrayList<>(List.of("java", "record"));
        TagsBad bad = new TagsBad("post", mutableList);

        System.out.println(bad.tags());       // Output: [java, record]
        mutableList.add("hacked");            // Modifying the original list
        System.out.println(bad.tags());       // Output: [java, record, hacked] -- broken!

        // Demonstrating the fix with defensive copy
        var anotherList = new java.util.ArrayList<>(List.of("java", "record"));
        TagsGood good = new TagsGood("post", anotherList);

        System.out.println(good.tags());      // Output: [java, record]
        anotherList.add("hacked");            // Modifying the original list
        System.out.println(good.tags());      // Output: [java, record] -- safe!

        try {
            good.tags().add("oops");          // Trying to modify the copy
        } catch (UnsupportedOperationException e) {
            System.out.println("Cannot modify -- list is unmodifiable");
            // Output: Cannot modify -- list is unmodifiable
        }
    }
}

5. Custom Constructors

Records can have additional constructors beyond the canonical one. Every custom constructor must eventually delegate to the canonical constructor using this(...). This ensures that all validation and normalization logic in the canonical (or compact) constructor is always executed.

public class CustomConstructorDemo {

    record Range(int low, int high) {
        // Compact constructor for validation
        Range {
            if (low > high) {
                throw new IllegalArgumentException(
                    "low (" + low + ") must be <= high (" + high + ")");
            }
        }

        // Convenience constructor: single value range
        Range(int value) {
            this(value, value);  // Delegates to canonical constructor
        }

        // Convenience constructor: from zero to a value
        static Range upTo(int high) {
            return new Range(0, high);
        }

        int span() {
            return high - low;
        }

        boolean contains(int value) {
            return value >= low && value <= high;
        }
    }

    // Copy-with-modification pattern
    record UserProfile(String name, String email, String role) {
        // "Wither" methods -- return a new record with one field changed
        UserProfile withName(String newName) {
            return new UserProfile(newName, this.email, this.role);
        }

        UserProfile withEmail(String newEmail) {
            return new UserProfile(this.name, newEmail, this.role);
        }

        UserProfile withRole(String newRole) {
            return new UserProfile(this.name, this.email, newRole);
        }
    }

    public static void main(String[] args) {
        // Using the canonical constructor
        Range full = new Range(1, 100);
        System.out.println(full);              // Output: Range[low=1, high=100]

        // Using the convenience constructor
        Range single = new Range(42);
        System.out.println(single);            // Output: Range[low=42, high=42]

        // Using the static factory method
        Range zeroTo = Range.upTo(10);
        System.out.println(zeroTo);            // Output: Range[low=0, high=10]

        // Instance methods
        System.out.println(full.span());       // Output: 99
        System.out.println(full.contains(50)); // Output: true

        // Copy-with-modification
        UserProfile alice = new UserProfile("Alice", "alice@example.com", "user");
        UserProfile admin = alice.withRole("admin");
        System.out.println(alice);
        // Output: UserProfile[name=Alice, email=alice@example.com, role=user]
        System.out.println(admin);
        // Output: UserProfile[name=Alice, email=alice@example.com, role=admin]
    }
}

6. Adding Methods and Implementing Interfaces

Records can contain instance methods, static methods, static fields, and can implement interfaces. What they cannot do is extend another class (they implicitly extend java.lang.Record) or declare instance fields beyond the components.

This section demonstrates how to add behavior to records and how to make them participate in the Java type system through interfaces.

import java.util.Objects;

public class RecordMethodsDemo {

    // Record implementing Comparable
    record Money(double amount, String currency) implements Comparable {
        // Compact constructor for validation
        Money {
            if (amount < 0) {
                throw new IllegalArgumentException("Amount cannot be negative: " + amount);
            }
            Objects.requireNonNull(currency, "Currency cannot be null");
            currency = currency.toUpperCase();
        }

        // Instance methods for business logic
        Money add(Money other) {
            requireSameCurrency(other);
            return new Money(this.amount + other.amount, this.currency);
        }

        Money subtract(Money other) {
            requireSameCurrency(other);
            return new Money(this.amount - other.amount, this.currency);
        }

        Money multiply(double factor) {
            return new Money(this.amount * factor, this.currency);
        }

        // Private helper method
        private void requireSameCurrency(Money other) {
            if (!this.currency.equals(other.currency)) {
                throw new IllegalArgumentException(
                    "Cannot operate on different currencies: " + currency + " vs " + other.currency);
            }
        }

        // Implementing Comparable
        @Override
        public int compareTo(Money other) {
            requireSameCurrency(other);
            return Double.compare(this.amount, other.amount);
        }

        // Static factory method
        static Money usd(double amount) {
            return new Money(amount, "USD");
        }

        static Money eur(double amount) {
            return new Money(amount, "EUR");
        }
    }

    public static void main(String[] args) {
        Money price = Money.usd(29.99);
        Money tax   = Money.usd(2.40);
        Money total  = price.add(tax);

        System.out.println("Price: " + price);    // Output: Money[amount=29.99, currency=USD]
        System.out.println("Tax:   " + tax);      // Output: Money[amount=2.4, currency=USD]
        System.out.println("Total: " + total);     // Output: Money[amount=32.39, currency=USD]

        Money discounted = total.multiply(0.9);
        System.out.println("After 10% off: " + discounted);
        // Output: After 10% off: Money[amount=29.151, currency=USD]

        // Comparable works -- useful for sorting
        System.out.println(price.compareTo(tax) > 0);  // Output: true

        // Currency mismatch throws exception
        try {
            Money euros = Money.eur(25.00);
            price.add(euros);
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage());
            // Output: Cannot operate on different currencies: USD vs EUR
        }
    }
}

6.1 Records Implementing Multiple Interfaces

A record can implement as many interfaces as needed. This is particularly useful when you want records to participate in functional programming patterns or framework contracts.

import java.io.Serializable;
import java.util.function.Supplier;

public class MultiInterfaceDemo {

    // A record implementing multiple interfaces
    interface Printable {
        String toPrettyString();
    }

    interface Exportable {
        String toCsv();
    }

    record Product(String sku, String name, double price)
            implements Printable, Exportable, Serializable, Comparable {

        @Override
        public String toPrettyString() {
            return String.format("%-10s | %-20s | $%8.2f", sku, name, price);
        }

        @Override
        public String toCsv() {
            return String.join(",", sku, name, String.valueOf(price));
        }

        @Override
        public int compareTo(Product other) {
            return Double.compare(this.price, other.price);
        }
    }

    public static void main(String[] args) {
        var products = java.util.List.of(
            new Product("SKU-001", "Mechanical Keyboard", 149.99),
            new Product("SKU-002", "USB-C Hub", 39.99),
            new Product("SKU-003", "Monitor Stand", 79.99)
        );

        // Using Printable interface
        System.out.println("Product Listing:");
        products.forEach(p -> System.out.println(p.toPrettyString()));
        // Output:
        // Product Listing:
        // SKU-001    | Mechanical Keyboard  |  $ 149.99
        // SKU-002    | USB-C Hub            |  $  39.99
        // SKU-003    | Monitor Stand        |  $  79.99

        // Using Exportable interface
        System.out.println("\nCSV Export:");
        products.forEach(p -> System.out.println(p.toCsv()));
        // Output:
        // CSV Export:
        // SKU-001,Mechanical Keyboard,149.99
        // SKU-002,USB-C Hub,39.99
        // SKU-003,Monitor Stand,79.99

        // Using Comparable -- sorting by price
        products.stream()
                .sorted()
                .forEach(p -> System.out.println(p.name() + ": $" + p.price()));
        // Output:
        // USB-C Hub: $39.99
        // Monitor Stand: $79.99
        // Mechanical Keyboard: $149.99
    }
}

7. Records with Generics

Records fully support generics, making them excellent for creating reusable data containers. Generic records are particularly useful for wrapper types, pairs, result types, and API response structures.

import java.util.Optional;
import java.util.List;

public class GenericRecordsDemo {

    // A generic pair -- holds two values of potentially different types
    record Pair(A first, B second) {
        // Factory method for type inference
        static  Pair of(A first, B second) {
            return new Pair<>(first, second);
        }

        // Transform one side
         Pair mapFirst(java.util.function.Function fn) {
            return new Pair<>(fn.apply(first), second);
        }

         Pair mapSecond(java.util.function.Function fn) {
            return new Pair<>(first, fn.apply(second));
        }
    }

    // A Result type -- represents success or failure (like Rust's Result)
    sealed interface Result {
        record Success(T value) implements Result { }
        record Failure(String error, Exception cause) implements Result {
            Failure(String error) {
                this(error, null);
            }
        }
    }

    // A generic triple
    record Triple(A first, B second, C third) { }

    // Bounded type parameter
    record MinMax>(T min, T max) {
        MinMax {
            if (min.compareTo(max) > 0) {
                throw new IllegalArgumentException(
                    "min (" + min + ") must be <= max (" + max + ")");
            }
        }

        boolean contains(T value) {
            return value.compareTo(min) >= 0 && value.compareTo(max) <= 0;
        }
    }

    public static void main(String[] args) {
        // Pair usage
        Pair entry = Pair.of("Alice", 30);
        System.out.println(entry);
        // Output: Pair[first=Alice, second=30]

        Pair uppered = entry.mapFirst(String::toUpperCase);
        System.out.println(uppered);
        // Output: Pair[first=ALICE, second=30]

        // Result usage
        Result success = new Result.Success<>(42);
        Result failure = new Result.Failure<>("Division by zero");

        // Pattern matching with Result (Java 21+)
        printResult(success);   // Output: Got value: 42
        printResult(failure);   // Output: Error: Division by zero

        // MinMax with bounded type
        MinMax range = new MinMax<>(1, 100);
        System.out.println(range.contains(50));    // Output: true
        System.out.println(range.contains(200));   // Output: false

        MinMax wordRange = new MinMax<>("apple", "mango");
        System.out.println(wordRange.contains("cherry")); // Output: true
        System.out.println(wordRange.contains("zebra"));  // Output: false
    }

    static  void printResult(Result result) {
        switch (result) {
            case Result.Success s -> System.out.println("Got value: " + s.value());
            case Result.Failure f -> System.out.println("Error: " + f.error());
        }
    }
}

8. Record Patterns (Java 21+)

Java 21 finalized record patterns, which let you destructure a record directly in instanceof checks and switch expressions. Instead of first checking the type and then calling accessor methods, you extract the components right in the pattern -- similar to destructuring in languages like Kotlin, Scala, or JavaScript.

Record patterns work because records are transparent: their components are known at compile time. The compiler can safely decompose a record into its parts.

public class RecordPatternsDemo {

    record Point(int x, int y) { }
    record Circle(Point center, double radius) { }
    record Rectangle(Point topLeft, Point bottomRight) { }

    sealed interface Shape permits CircleShape, RectangleShape, Line { }
    record CircleShape(Point center, double radius) implements Shape { }
    record RectangleShape(Point topLeft, int width, int height) implements Shape { }
    record Line(Point start, Point end) implements Shape { }

    public static void main(String[] args) {
        // 1. Record pattern in instanceof
        Object obj = new Point(3, 7);

        // Old way: check type, cast, then access
        if (obj instanceof Point p) {
            System.out.println("x=" + p.x() + ", y=" + p.y());
        }

        // New way: destructure in the pattern itself
        if (obj instanceof Point(int x, int y)) {
            System.out.println("x=" + x + ", y=" + y);
            // Output: x=3, y=7
        }

        // 2. Nested record patterns -- destructure a record inside a record
        Circle circle = new Circle(new Point(10, 20), 5.0);
        Object shapeObj = circle;

        if (shapeObj instanceof Circle(Point(int cx, int cy), double r)) {
            System.out.printf("Circle at (%d, %d) with radius %.1f%n", cx, cy, r);
            // Output: Circle at (10, 20) with radius 5.0
        }

        // 3. Record patterns in switch
        describeShape(new CircleShape(new Point(0, 0), 10));
        describeShape(new RectangleShape(new Point(5, 5), 100, 50));
        describeShape(new Line(new Point(0, 0), new Point(10, 10)));
    }

    static void describeShape(Shape shape) {
        String description = switch (shape) {
            // Destructure CircleShape and its nested Point in one pattern
            case CircleShape(Point(int x, int y), double r)
                    -> String.format("Circle at (%d,%d) r=%.0f", x, y, r);

            // Destructure RectangleShape
            case RectangleShape(Point(int x, int y), int w, int h)
                    -> String.format("Rectangle at (%d,%d) %dx%d", x, y, w, h);

            // Destructure Line with its two endpoints
            case Line(Point(int x1, int y1), Point(int x2, int y2))
                    -> String.format("Line from (%d,%d) to (%d,%d)", x1, y1, x2, y2);
        };
        System.out.println(description);
        // Output:
        // Circle at (0,0) r=10
        // Rectangle at (5,5) 100x50
        // Line from (0,0) to (10,10)
    }
}

8.1 Guarded Record Patterns

You can combine record patterns with guards (when clause) to add conditions beyond just the type match. This gives you powerful, readable conditional logic.

public class GuardedPatternsDemo {

    record Temperature(double value, String unit) {
        Temperature {
            unit = unit.toUpperCase();
        }
    }

    record Coordinate(double lat, double lon) { }

    static String describeTemperature(Temperature temp) {
        return switch (temp) {
            case Temperature(double v, String u) when v < 0 && u.equals("C")
                    -> "Below freezing (Celsius)";
            case Temperature(double v, String u) when v < 32 && u.equals("F")
                    -> "Below freezing (Fahrenheit)";
            case Temperature(double v, String u) when v > 100 && u.equals("C")
                    -> "Above boiling point (Celsius)";
            case Temperature(double v, String u) when v > 212 && u.equals("F")
                    -> "Above boiling point (Fahrenheit)";
            case Temperature(double v, String u)
                    -> String.format("%.1f°%s -- normal range", v, u);
        };
    }

    static String describeLocation(Coordinate coord) {
        return switch (coord) {
            case Coordinate(double lat, double lon) when lat == 0 && lon == 0
                    -> "Null Island (0,0)";
            case Coordinate(double lat, var lon) when lat > 66.5
                    -> "Arctic region";
            case Coordinate(double lat, var lon) when lat < -66.5
                    -> "Antarctic region";
            case Coordinate(double lat, double lon)
                    -> String.format("Location (%.2f, %.2f)", lat, lon);
        };
    }

    public static void main(String[] args) {
        System.out.println(describeTemperature(new Temperature(-10, "C")));
        // Output: Below freezing (Celsius)

        System.out.println(describeTemperature(new Temperature(72, "F")));
        // Output: 72.0°F -- normal range

        System.out.println(describeLocation(new Coordinate(0, 0)));
        // Output: Null Island (0,0)

        System.out.println(describeLocation(new Coordinate(78.2, 15.6)));
        // Output: Arctic region

        System.out.println(describeLocation(new Coordinate(40.7, -74.0)));
        // Output: Location (40.70, -74.00)
    }
}

9. Local Records

Records can be declared inside a method, making them local records. This is powerful when you need a temporary data structure for intermediate computation -- particularly in stream pipelines or complex method logic. The record's scope is limited to the method, keeping it out of the class's public API.

Local records are implicitly static (they do not capture the enclosing method's local variables or the enclosing instance), which makes them safe and predictable.

import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class LocalRecordsDemo {

    static void analyzeScores(List names, List scores) {
        // Local record -- only visible inside this method
        record StudentScore(String name, int score) { }

        // Combine two parallel lists into a structured stream
        var results = new java.util.ArrayList();
        for (int i = 0; i < names.size(); i++) {
            results.add(new StudentScore(names.get(i), scores.get(i)));
        }

        // Now use the local record for clear, type-safe stream processing
        var topStudents = results.stream()
                .filter(s -> s.score() >= 80)
                .sorted(Comparator.comparingInt(StudentScore::score).reversed())
                .toList();

        System.out.println("Top students:");
        topStudents.forEach(s ->
            System.out.printf("  %s: %d%n", s.name(), s.score()));

        double average = results.stream()
                .mapToInt(StudentScore::score)
                .average()
                .orElse(0);
        System.out.printf("Class average: %.1f%n", average);
    }

    static Map computeWeightedGrades(
            List students, List> allGrades, List weights) {

        // Local record to hold intermediate computation results
        record GradeEntry(String student, double weightedAverage) { }

        return java.util.stream.IntStream.range(0, students.size())
                .mapToObj(i -> {
                    double weightedSum = 0;
                    List grades = allGrades.get(i);
                    for (int j = 0; j < grades.size(); j++) {
                        weightedSum += grades.get(j) * weights.get(j);
                    }
                    return new GradeEntry(students.get(i), weightedSum);
                })
                .collect(Collectors.toMap(GradeEntry::student, GradeEntry::weightedAverage));
    }

    public static void main(String[] args) {
        var names  = List.of("Alice", "Bob", "Charlie", "Diana", "Eve");
        var scores = List.of(92, 78, 85, 96, 73);

        analyzeScores(names, scores);
        // Output:
        // Top students:
        //   Diana: 96
        //   Alice: 92
        //   Charlie: 85
        // Class average: 84.8

        var students = List.of("Alice", "Bob");
        var allGrades = List.of(
            List.of(90, 85, 92),  // Alice's grades
            List.of(78, 82, 88)   // Bob's grades
        );
        var weights = List.of(0.3, 0.3, 0.4);

        Map weighted = computeWeightedGrades(students, allGrades, weights);
        weighted.forEach((name, avg) ->
            System.out.printf("%s: weighted avg = %.1f%n", name, avg));
        // Output:
        // Alice: weighted avg = 89.3
        // Bob: weighted avg = 83.2
    }
}

10. Records and Serialization

Records have a special relationship with Java serialization. When a record implements Serializable, it is serialized and deserialized differently from ordinary classes -- and this difference makes records safer.

Key difference: Traditional class deserialization bypasses constructors entirely, using internal JVM mechanisms to reconstruct objects. This can lead to objects in invalid states because your validation logic never runs. Record deserialization, by contrast, always invokes the canonical constructor. This means your validation and normalization logic in the compact constructor executes during deserialization, guaranteeing that deserialized records are always valid.

import java.io.*;

public class RecordSerializationDemo {

    record Config(String host, int port, boolean ssl) implements Serializable {
        Config {
            if (host == null || host.isBlank()) {
                throw new IllegalArgumentException("Host cannot be blank");
            }
            if (port < 1 || port > 65535) {
                throw new IllegalArgumentException("Port must be 1-65535, got: " + port);
            }
            host = host.strip().toLowerCase();
        }
    }

    public static void main(String[] args) throws Exception {
        Config original = new Config("  API.Example.COM  ", 8443, true);
        System.out.println("Original: " + original);
        // Output: Original: Config[host=api.example.com, port=8443, ssl=true]

        // Serialize to bytes
        byte[] bytes;
        try (var baos = new ByteArrayOutputStream();
             var oos = new ObjectOutputStream(baos)) {
            oos.writeObject(original);
            bytes = baos.toByteArray();
        }
        System.out.println("Serialized to " + bytes.length + " bytes");

        // Deserialize -- the canonical constructor runs, so validation is enforced
        Config restored;
        try (var bais = new ByteArrayInputStream(bytes);
             var ois = new ObjectInputStream(bais)) {
            restored = (Config) ois.readObject();
        }

        System.out.println("Restored: " + restored);
        // Output: Restored: Config[host=api.example.com, port=8443, ssl=true]

        // The deserialized record is equal to the original
        System.out.println("Equal: " + original.equals(restored));
        // Output: Equal: true

        // Deserialization safety: if someone tampers with the byte stream
        // to set port to -1, the canonical constructor would throw
        // IllegalArgumentException -- the invalid object never gets created.
    }
}

11. Record vs Traditional Class

Records are not a replacement for all classes. They serve a specific purpose: carrying immutable data transparently. Here is a detailed comparison to help you decide which to use.

Feature Record Traditional Class
Purpose Transparent, immutable data carrier General-purpose modeling with behavior and state
Fields Implicitly private final, defined by components only Any access modifier, mutable or immutable
Additional instance fields Not allowed Allowed
Inheritance Cannot extend classes (implicitly extends Record), is final Can extend one class, can be subclassed
Interfaces Can implement any number of interfaces Can implement any number of interfaces
Constructor Canonical constructor auto-generated, compact constructor available Must write constructors manually
equals/hashCode Auto-generated based on all components Must write manually (or use IDE/Lombok)
toString Auto-generated with component names and values Must write manually
Mutability Immutable by design (fields are final, no setters) Can be mutable or immutable
Boilerplate Minimal -- one line for basic records Significant -- constructor, getters, equals, hashCode, toString
Serialization Safe -- uses canonical constructor for deserialization Fragile -- bypasses constructors, can create invalid objects
Pattern matching Supports record patterns and destructuring (Java 21+) Supports only type patterns
Best for DTOs, value objects, API responses, map keys, config Entities with identity, mutable state, complex behavior

When to Use What

Scenario Use Why
Data transfer between layers (DTO) Record Pure data, no behavior, immutable
Database entity with JPA Class JPA requires a no-arg constructor and mutable setters
Value object (Money, Email, DateRange) Record Equality based on value, immutable
Mutable builder or accumulator Class State changes over time
Fixed set of constants Enum Finite, known instances
API response payload Record Structured data, easy to destructure
Complex domain model with behavior Class Encapsulation, inheritance, mutable state
Composite map key Record Auto-generated equals/hashCode

12. Common Use Cases

This section shows real-world scenarios where records shine. Each example is something you will encounter in production Java code.

12.1 DTOs (Data Transfer Objects)

DTOs carry data between layers of an application -- from a REST controller to a service, from a service to a repository, or from an API to the client. Records are a perfect fit because DTOs are pure data with no behavior and no identity.

import java.time.LocalDate;
import java.util.List;

public class DtoExample {

    // Request DTO -- data coming in from the client
    record CreateUserRequest(String username, String email, String password) {
        CreateUserRequest {
            if (username == null || username.isBlank())
                throw new IllegalArgumentException("Username is required");
            if (email == null || !email.contains("@"))
                throw new IllegalArgumentException("Valid email is required");
            if (password == null || password.length() < 8)
                throw new IllegalArgumentException("Password must be at least 8 characters");

            username = username.strip().toLowerCase();
            email = email.strip().toLowerCase();
        }
    }

    // Response DTO -- data going out to the client (no password!)
    record UserResponse(long id, String username, String email, LocalDate createdAt) { }

    // Paginated response
    record Page(List items, int pageNumber, int pageSize, long totalElements) {
        int totalPages() {
            return (int) Math.ceil((double) totalElements / pageSize);
        }

        boolean hasNext() {
            return pageNumber < totalPages() - 1;
        }

        boolean hasPrevious() {
            return pageNumber > 0;
        }
    }

    public static void main(String[] args) {
        // Creating a request DTO with validation
        var request = new CreateUserRequest("  JohnDoe  ", "john@example.com", "securePass123");
        System.out.println(request);
        // Output: CreateUserRequest[username=johndoe, email=john@example.com, password=securePass123]

        // Creating a response DTO
        var response = new UserResponse(1L, "johndoe", "john@example.com", LocalDate.now());
        System.out.println(response);
        // Output: UserResponse[id=1, username=johndoe, email=john@example.com, createdAt=2026-02-28]

        // Paginated response
        var page = new Page<>(
            List.of(response),
            0, 10, 42
        );
        System.out.println("Total pages: " + page.totalPages());  // Output: Total pages: 5
        System.out.println("Has next: " + page.hasNext());         // Output: Has next: true
    }
}

12.2 Composite Map Keys

One of the most practical uses of records is as composite map keys. Because records auto-generate equals() and hashCode() based on all components, they work correctly in HashMap and HashSet without any extra effort. With traditional classes, forgetting to implement these methods is a common and subtle bug.

import java.util.HashMap;
import java.util.Map;

public class CompositeKeyDemo {

    // Composite key for a cache
    record CacheKey(String userId, String resource, String locale) { }

    // Composite key for a grid or matrix
    record GridCell(int row, int col) { }

    public static void main(String[] args) {
        // Using record as HashMap key
        Map cache = new HashMap<>();
        cache.put(new CacheKey("user-42", "/api/profile", "en-US"), "{\"name\":\"Alice\"}");
        cache.put(new CacheKey("user-42", "/api/profile", "fr-FR"), "{\"nom\":\"Alice\"}");
        cache.put(new CacheKey("user-99", "/api/profile", "en-US"), "{\"name\":\"Bob\"}");

        // Lookup works correctly because equals/hashCode are based on all components
        CacheKey lookup = new CacheKey("user-42", "/api/profile", "en-US");
        System.out.println(cache.get(lookup));
        // Output: {"name":"Alice"}

        // Grid example
        Map board = new HashMap<>();
        board.put(new GridCell(0, 0), "X");
        board.put(new GridCell(0, 1), "O");
        board.put(new GridCell(1, 1), "X");

        System.out.println("Center: " + board.get(new GridCell(1, 1)));
        // Output: Center: X
    }
}

12.3 Stream Intermediate Results

When processing data with streams, you often need to carry multiple values through the pipeline. Records (especially local records) provide a clean, type-safe way to do this instead of using arrays, maps, or Object[].

import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;

public class StreamRecordDemo {

    record Sale(String product, String region, double amount) { }

    public static void main(String[] args) {
        var sales = List.of(
            new Sale("Laptop",  "North", 1200.00),
            new Sale("Laptop",  "South", 1100.00),
            new Sale("Laptop",  "North", 1300.00),
            new Sale("Phone",   "North",  800.00),
            new Sale("Phone",   "South",  750.00),
            new Sale("Tablet",  "North",  500.00),
            new Sale("Tablet",  "South",  450.00),
            new Sale("Tablet",  "South",  520.00)
        );

        // Local record for aggregated results
        record ProductSummary(String product, long count, double total, double average) { }

        // Group by product and compute summary statistics
        var summaries = sales.stream()
                .collect(Collectors.groupingBy(Sale::product))
                .entrySet()
                .stream()
                .map(entry -> {
                    String product = entry.getKey();
                    List productSales = entry.getValue();
                    long count = productSales.size();
                    double total = productSales.stream().mapToDouble(Sale::amount).sum();
                    double avg = total / count;
                    return new ProductSummary(product, count, total, avg);
                })
                .sorted(Comparator.comparingDouble(ProductSummary::total).reversed())
                .toList();

        System.out.println("Sales Summary (sorted by total revenue):");
        summaries.forEach(s -> System.out.printf(
            "  %-8s | %d sales | $%,.2f total | $%,.2f avg%n",
            s.product(), s.count(), s.total(), s.average()));
        // Output:
        // Sales Summary (sorted by total revenue):
        //   Laptop   | 3 sales | $3,600.00 total | $1,200.00 avg
        //   Phone    | 2 sales | $1,550.00 total | $775.00 avg
        //   Tablet   | 3 sales | $1,470.00 total | $490.00 avg
    }
}

13. Limitations and Restrictions

Records are deliberately restricted to keep them focused on their purpose as transparent data carriers. Understanding these restrictions helps you avoid surprises and choose the right tool for the job.

Restriction Explanation
Cannot extend classes Records implicitly extend java.lang.Record. Since Java does not support multiple inheritance of classes, you cannot extend any other class.
Cannot be abstract Records are implicitly final. They cannot be declared abstract and cannot be subclassed.
All fields are final The component fields are implicitly private final. There are no setter methods and no way to change field values after construction.
Cannot declare instance fields The only fields a record can have are those declared in the component list. You can declare static fields but not additional instance fields.
No native declarations Record methods cannot be declared native.
Component types are restricted in annotations Annotations on record components apply to multiple targets (field, accessor method, constructor parameter). Annotation authors must use @Target to control where they apply.
public class RecordLimitations {

    record Point(int x, int y) { }

    // COMPILE ERROR: cannot extend a class
    // record Point3D(int x, int y, int z) extends Point { }

    // COMPILE ERROR: cannot be abstract
    // abstract record Shape(String type) { }

    // COMPILE ERROR: cannot add instance fields
    // record Person(String name) {
    //     int age;  // Not allowed -- only static fields are permitted
    // }

    // COMPILE ERROR: cannot have a no-arg constructor that does not delegate
    // record Config(String key, String value) {
    //     Config() { }  // Must delegate to canonical: this(key, value)
    // }

    // This IS allowed: static fields and methods
    record Constants(String name, double value) {
        static final Constants PI = new Constants("pi", Math.PI);
        static final Constants E  = new Constants("e", Math.E);

        // Static field -- OK
        static int instanceCount = 0;
    }

    // This IS allowed: implementing interfaces
    record Timestamp(long epochMillis) implements Comparable {
        @Override
        public int compareTo(Timestamp other) {
            return Long.compare(this.epochMillis, other.epochMillis);
        }
    }

    public static void main(String[] args) {
        // Records are final -- cannot be subclassed
        System.out.println(Point.class.getSuperclass());
        // Output: class java.lang.Record

        // Verify the class is final
        System.out.println(java.lang.reflect.Modifier.isFinal(Point.class.getModifiers()));
        // Output: true

        // isRecord() check (Java 16+)
        System.out.println(Point.class.isRecord());
        // Output: true

        // Get record components via reflection
        var components = Point.class.getRecordComponents();
        for (var comp : components) {
            System.out.println(comp.getName() + " : " + comp.getType().getSimpleName());
        }
        // Output:
        // x : int
        // y : int
    }
}

14. Best Practices

After working with records across projects of varying sizes, several patterns consistently lead to clean, maintainable code. Here are the practices that experienced Java developers follow.

14.1 Use Records for Data, Not Behavior

Records should primarily carry data. Adding a few convenience methods is fine, but if you find yourself adding complex business logic, you probably want a class instead.

14.2 Always Validate in the Compact Constructor

Since every constructor must delegate to the canonical constructor, validation in the compact constructor guarantees that no invalid instance can ever exist -- including deserialized instances.

14.3 Defensively Copy Mutable Components

If a record component is a mutable type (List, Date, arrays), always create an unmodifiable copy in the compact constructor. A record that appears immutable but holds mutable references is a bug waiting to happen.

14.4 Prefer Records Over Lombok @Data for New Code

If you are writing new Java 16+ code and need an immutable data carrier, use a record instead of Lombok's @Data or @Value. Records are a language feature -- they require no annotation processor, no IDE plugin, and they work with pattern matching.

14.5 Use Records with Sealed Interfaces

Records and sealed interfaces are designed to work together. A sealed interface with record implementations gives you exhaustive pattern matching -- the compiler guarantees that your switch handles every case.

import java.util.List;

public class BestPracticesDemo {

    // Best practice: sealed interface + records = exhaustive pattern matching
    sealed interface PaymentMethod permits CreditCard, BankTransfer, DigitalWallet { }

    record CreditCard(String number, String expiry, String cvv) implements PaymentMethod {
        CreditCard {
            // Validate and mask the number
            if (number == null || number.replaceAll("\\s", "").length() != 16) {
                throw new IllegalArgumentException("Credit card number must be 16 digits");
            }
            number = number.replaceAll("\\s", "");
            if (cvv == null || !cvv.matches("\\d{3,4}")) {
                throw new IllegalArgumentException("CVV must be 3 or 4 digits");
            }
        }

        // Convenience: masked number for display
        String maskedNumber() {
            return "****-****-****-" + number.substring(12);
        }
    }

    record BankTransfer(String iban, String bic) implements PaymentMethod {
        BankTransfer {
            if (iban == null || iban.isBlank()) {
                throw new IllegalArgumentException("IBAN is required");
            }
            iban = iban.replaceAll("\\s", "").toUpperCase();
        }
    }

    record DigitalWallet(String provider, String accountId) implements PaymentMethod {
        DigitalWallet {
            if (provider == null || provider.isBlank()) {
                throw new IllegalArgumentException("Provider is required");
            }
            provider = provider.strip();
        }
    }

    // Exhaustive switch -- compiler ensures all cases are handled
    static String processPayment(PaymentMethod method, double amount) {
        return switch (method) {
            case CreditCard cc ->
                String.format("Charging $%.2f to card %s", amount, cc.maskedNumber());
            case BankTransfer bt ->
                String.format("Transferring $%.2f to IBAN %s", amount, bt.iban());
            case DigitalWallet dw ->
                String.format("Sending $%.2f via %s (%s)", amount, dw.provider(), dw.accountId());
        };
    }

    public static void main(String[] args) {
        List payments = List.of(
            new CreditCard("4111 1111 1111 1234", "12/25", "123"),
            new BankTransfer("DE89 3704 0044 0532 0130 00", "COBADEFFXXX"),
            new DigitalWallet("PayPal", "alice@example.com")
        );

        payments.forEach(pm ->
            System.out.println(processPayment(pm, 99.99)));
        // Output:
        // Charging $99.99 to card ****-****-****-1234
        // Transferring $99.99 to IBAN DE89370400440532013000
        // Sending $99.99 via PayPal (alice@example.com)
    }
}

15. Complete Practical Example: API Response Handling System

This final example ties together everything we have covered -- generic records, sealed interfaces, record patterns, compact constructors, defensive copying, and stream processing -- into a realistic API response handling system. This is the kind of code you would write in a production Spring Boot or microservice application.

import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;

public class ApiResponseSystem {

    // ─── Domain Records ───────────────────────────────────────────────

    record Address(String street, String city, String state, String zipCode, String country) {
        Address {
            Objects.requireNonNull(street, "Street is required");
            Objects.requireNonNull(city, "City is required");
            Objects.requireNonNull(country, "Country is required");
            country = country.toUpperCase();
        }

        String formatted() {
            return String.format("%s, %s, %s %s, %s", street, city, state, zipCode, country);
        }
    }

    record User(long id, String name, String email, Address address, List roles) {
        User {
            Objects.requireNonNull(name, "Name is required");
            Objects.requireNonNull(email, "Email is required");
            email = email.strip().toLowerCase();
            roles = roles == null ? List.of() : List.copyOf(roles);
        }

        boolean hasRole(String role) {
            return roles.contains(role);
        }
    }

    record ErrorDetail(String field, String code, String message) {
        ErrorDetail {
            Objects.requireNonNull(code, "Error code is required");
            Objects.requireNonNull(message, "Error message is required");
        }
    }

    // ─── Generic API Response ─────────────────────────────────────────

    sealed interface ApiResponse {
        Instant timestamp();

        record Success(T data, Instant timestamp, Map metadata)
                implements ApiResponse {
            Success(T data) {
                this(data, Instant.now(), Map.of());
            }

            Success(T data, Map metadata) {
                this(data, Instant.now(), metadata);
            }

            Success {
                Objects.requireNonNull(data, "Success response must have data");
                metadata = metadata == null ? Map.of() : Map.copyOf(metadata);
            }
        }

        record Failure(List errors, int statusCode, Instant timestamp)
                implements ApiResponse {
            Failure(List errors, int statusCode) {
                this(errors, statusCode, Instant.now());
            }

            Failure {
                if (errors == null || errors.isEmpty()) {
                    throw new IllegalArgumentException("Failure must have at least one error");
                }
                errors = List.copyOf(errors);
                if (statusCode < 400 || statusCode > 599) {
                    throw new IllegalArgumentException(
                        "Status code must be 4xx or 5xx, got: " + statusCode);
                }
            }
        }
    }

    // Paginated response built on records
    record PagedResponse(List items, int page, int size, long totalItems) {
        PagedResponse {
            items = items == null ? List.of() : List.copyOf(items);
            if (page < 0) throw new IllegalArgumentException("Page cannot be negative");
            if (size < 1) throw new IllegalArgumentException("Size must be at least 1");
        }

        long totalPages() {
            return (totalItems + size - 1) / size;
        }

        boolean hasNext() {
            return page < totalPages() - 1;
        }
    }

    // ─── Service Layer ────────────────────────────────────────────────

    static ApiResponse fetchUser(long id) {
        // Simulating an API call
        if (id <= 0) {
            return new ApiResponse.Failure<>(
                List.of(new ErrorDetail("id", "INVALID_ID", "User ID must be positive")),
                400
            );
        }
        if (id == 999) {
            return new ApiResponse.Failure<>(
                List.of(new ErrorDetail(null, "NOT_FOUND", "User not found: " + id)),
                404
            );
        }

        User user = new User(
            id,
            "Alice Johnson",
            "alice@example.com",
            new Address("123 Main St", "Springfield", "IL", "62701", "us"),
            List.of("USER", "ADMIN")
        );
        return new ApiResponse.Success<>(user, Map.of("source", "database", "cached", "false"));
    }

    static ApiResponse> fetchUsers(int page, int size) {
        List users = List.of(
            new User(1, "Alice", "alice@example.com",
                new Address("123 Main St", "Springfield", "IL", "62701", "US"),
                List.of("ADMIN")),
            new User(2, "Bob", "bob@example.com",
                new Address("456 Oak Ave", "Portland", "OR", "97201", "US"),
                List.of("USER")),
            new User(3, "Charlie", "charlie@example.com",
                new Address("789 Pine Rd", "Austin", "TX", "73301", "US"),
                List.of("USER", "EDITOR"))
        );

        var paged = new PagedResponse<>(users, page, size, 42);
        return new ApiResponse.Success<>(paged);
    }

    // ─── Response Processing with Pattern Matching ────────────────────

    static void handleUserResponse(ApiResponse response) {
        switch (response) {
            case ApiResponse.Success(User user, var timestamp, var metadata) -> {
                System.out.println("=== User Found ===");
                System.out.println("  Name:    " + user.name());
                System.out.println("  Email:   " + user.email());
                System.out.println("  Address: " + user.address().formatted());
                System.out.println("  Roles:   " + String.join(", ", user.roles()));
                System.out.println("  Is admin: " + user.hasRole("ADMIN"));
                System.out.println("  Metadata: " + metadata);
            }

            case ApiResponse.Failure(var errors, int status, var timestamp) -> {
                System.out.println("=== Request Failed (HTTP " + status + ") ===");
                errors.forEach(e -> System.out.printf(
                    "  [%s] %s: %s%n",
                    e.code(), e.field() != null ? e.field() : "general", e.message()));
            }
        }
    }

    // ─── Main ─────────────────────────────────────────────────────────

    public static void main(String[] args) {
        // Scenario 1: Successful user fetch
        System.out.println("--- Fetch user 1 ---");
        handleUserResponse(fetchUser(1));
        // Output:
        // --- Fetch user 1 ---
        // === User Found ===
        //   Name:    Alice Johnson
        //   Email:   alice@example.com
        //   Address: 123 Main St, Springfield, IL 62701, US
        //   Roles:   USER, ADMIN
        //   Is admin: true
        //   Metadata: {source=database, cached=false}

        // Scenario 2: User not found
        System.out.println("\n--- Fetch user 999 ---");
        handleUserResponse(fetchUser(999));
        // Output:
        // --- Fetch user 999 ---
        // === Request Failed (HTTP 404) ===
        //   [NOT_FOUND] general: User not found: 999

        // Scenario 3: Invalid request
        System.out.println("\n--- Fetch user -1 ---");
        handleUserResponse(fetchUser(-1));
        // Output:
        // --- Fetch user -1 ---
        // === Request Failed (HTTP 400) ===
        //   [INVALID_ID] id: User ID must be positive

        // Scenario 4: Paginated listing with stream processing
        System.out.println("\n--- Fetch users page 0 ---");
        var usersResponse = fetchUsers(0, 10);

        if (usersResponse instanceof ApiResponse.Success>(
                PagedResponse paged, var ts, var meta)) {

            System.out.println("Page " + (paged.page() + 1) + " of " + paged.totalPages());
            System.out.println("Has next page: " + paged.hasNext());

            // Stream processing: find admins
            var admins = paged.items().stream()
                    .filter(u -> u.hasRole("ADMIN"))
                    .map(User::name)
                    .toList();
            System.out.println("Admins on this page: " + admins);

            // Stream processing: group by state
            var byState = paged.items().stream()
                    .collect(Collectors.groupingBy(
                        u -> u.address().state(),
                        Collectors.mapping(User::name, Collectors.toList())));
            System.out.println("Users by state: " + byState);
        }
        // Output:
        // --- Fetch users page 0 ---
        // Page 1 of 5
        // Has next page: true
        // Admins on this page: [Alice]
        // Users by state: {TX=[Charlie], OR=[Bob], IL=[Alice]}
    }
}

Summary

Java Records are one of the most impactful additions to the language since lambdas. They eliminate boilerplate, enforce immutability, and enable powerful pattern matching. Here is what to remember:

  • Records are transparent, immutable data carriers -- they replace verbose POJOs with a single-line declaration.
  • The compiler generates the constructor, accessors (without get prefix), equals(), hashCode(), and toString().
  • Use compact constructors for validation, normalization, and defensive copying.
  • Records can implement interfaces and contain instance methods, static methods, and static fields -- but cannot extend classes or declare instance fields.
  • Generic records enable reusable container types like Pair, Result, and Page.
  • Record patterns (Java 21+) allow destructuring in instanceof and switch, making records even more powerful when combined with sealed interfaces.
  • Records are serialization-safe -- deserialization uses the canonical constructor, so validation always runs.
  • Use records for DTOs, value objects, API responses, composite map keys, and stream intermediate results.
  • Do not use records for JPA entities, mutable objects, or classes with complex behavior and inheritance hierarchies.

Start using records today in any Java 16+ project. Your codebase will be shorter, safer, and easier to maintain.




Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Leave a Reply

Your email address will not be published. Required fields are marked *