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:
get prefix)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
}
}
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);
}
}
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
}
}
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
}
}
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
}
}
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
}
}
}
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]
}
}
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
}
}
}
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
}
}
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());
}
}
}
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)
}
}
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)
}
}
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
}
}
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.
}
}
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 |
| 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 |
This section shows real-world scenarios where records shine. Each example is something you will encounter in production Java code.
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
}
}
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
}
}
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
}
}
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
}
}
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.
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.
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.
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.
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.
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)
}
}
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]}
}
}
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:
get prefix), equals(), hashCode(), and toString().Pair, Result, and Page.instanceof and switch, making records even more powerful when combined with sealed interfaces.Start using records today in any Java 16+ project. Your codebase will be shorter, safer, and easier to maintain.