Chaturmind
LearnDSASystem DesignDevOpsEngineering GrowthBlog
Start learning
Chaturmind

Structured learning paths for engineers who want to go deep. Written by practitioners.

Learn

  • Java
  • DSA
  • System Design
  • Spring Boot
  • AI / ML
  • DevOps
  • Engineering Growth

Company

  • Blog
  • Contact

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Chaturmind. All rights reserved.

Built for engineers who want to go deep.


← Java 21 — New Features

Data-Oriented Programming

  • Records
  • Sealed Classes
  • Pattern Matching

Virtual Threads (Project Loom)

  • Virtual Threads
Chaturmind
← Java 21 — New Features

Data-Oriented Programming

  • Records
  • Sealed Classes
  • Pattern Matching

Virtual Threads (Project Loom)

  • Virtual Threads
HomeLearnJavaJava 21 — New FeaturesData-Oriented Programming
✓ FreeBeginner· 7 min read

Records

Immutable data carriers without boilerplate — records replace POJOs in most scenarios.

Published September 21, 2026


Java Records

A record (final in Java 16) is a special kind of class for one job: carrying data. You declare the fields once, in the header, and the compiler writes everything else. That means a constructor, accessor methods, equals(), hashCode() and toString(). The result is a class that is immutable, compared by value, and a fraction of the size of the equivalent hand-written class.

// A traditional value class: fields, constructor, accessors, equals, hashCode, toString (~40 lines)
public final class Point {
    private final int x;
    private final int y;
    public Point(int x, int y) { this.x = x; this.y = y; }
    public int x() { return x; }
    public int y() { return y; }
    @Override public boolean equals(Object o) { /* compare x and y */ }
    @Override public int hashCode() { return Objects.hash(x, y); }
    @Override public String toString() { return "Point[x=" + x + ", y=" + y + "]"; }
}

// The same thing as a record
public record Point(int x, int y) {}

What the compiler generates

For record Point(int x, int y) you get:

Generated memberBehaviour
private final int x, yOne field per component (the names in the header); always private final
Canonical constructor Point(int x, int y)Assigns every field
Accessors x() and y()Named after the component, not getX()
equals(Object)True when the other object is a Point with equal components
hashCode()Derived from all components
toString()Point[x=3, y=4]

The class is implicitly final (no subclasses) and extends java.lang.Record. Because equality is based on the components, two records holding the same data are equal. That's exactly what you want for DTOs, map keys and value objects.

Point a = new Point(3, 4);
Point b = new Point(3, 4);
a == b;          // false — different objects
a.equals(b);     // true  — same data
Set.of(a).contains(b);   // true — works as a key because hashCode matches

Adding validation: the compact constructor

You'll often want to reject bad data or normalize it. A compact constructor is a constructor without a parameter list. It runs before the fields are assigned, and you can reassign the parameters:

public record Email(String value) {
    public Email {                                        // compact constructor
        Objects.requireNonNull(value, "email is required");
        value = value.trim().toLowerCase();               // normalize before assignment
        if (!value.contains("@")) throw new IllegalArgumentException("invalid email: " + value);
    }
}

This is a big part of why records are useful: once an Email exists, it's guaranteed valid. Code that receives an Email never has to re-check it.

What else a record can contain

public record Money(BigDecimal amount, String currency) implements Comparable<Money> {

    public static final Money ZERO_USD = new Money(BigDecimal.ZERO, "USD");   // static fields: allowed

    public static Money usd(String amount) {                                   // static factories: allowed
        return new Money(new BigDecimal(amount), "USD");
    }

    public Money plus(Money other) {                                          // instance methods: allowed
        if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch");
        return new Money(amount.add(other.amount), currency);                 // "change" = return a new record
    }

    @Override public int compareTo(Money o) { return amount.compareTo(o.amount); }   // interfaces: allowed
}

What records cannot have:

  • Extra instance fields beyond the components. All state must be in the header.
  • A superclass. They already extend Record, although they can implement any interfaces.
  • Mutable fields. Every component field is final, and there are no setters.

The shallow-immutability gotcha

A record's fields are final, but the objects they point to may not be:

public record Order(String id, List<String> items) {}

List<String> list = new ArrayList<>(List.of("book"));
Order order = new Order("o1", list);
list.add("pen");                 // the "immutable" order now has 2 items!
order.items().add("mug");        // and callers can modify it through the accessor too

The fix is a defensive copy in the compact constructor:

public record Order(String id, List<String> items) {
    public Order {
        items = List.copyOf(items);     // unmodifiable copy; also rejects null elements
    }
}

Arrays have a second problem. The generated equals() compares array components by reference, not content, so two records holding equal arrays are not equal. Prefer List over arrays in records.

Where records fit well

  • DTOs and API request/response bodies. Jackson (2.12+) serializes and deserializes records out of the box, and Bean Validation annotations work on components.
    public record CreateUserRequest(@NotBlank String name, @Email @NotBlank String email) {}
    
  • Value objects in the domain: Money, Email, DateRange. Validated once, compared by value.
  • Compound map keys: record Cell(int row, int col) as a HashMap key in grid/graph problems. It's correct equals/hashCode for free.
  • Returning several values from a method without a vague Pair class: record MinMax(int min, int max).
  • Local records, declared inside a method, for intermediate results in a stream pipeline.
  • Pattern matching (Java 21): record components can be taken apart directly in instanceof and switch, e.g. if (shape instanceof Circle(var r)). See the Pattern Matching lesson.

Where records don't fit

  • JPA/Hibernate entities. JPA needs a no-argument constructor, mutable fields and non-final classes it can proxy. Records have none of those. Keep entities as classes, and use records for the DTOs you map them to.
  • Objects with identity and changing state (a ShoppingCart that items are added to). A record models values, not things that change over time.
  • Classes that need inheritance of state from a base class.

Records vs Lombok @Value

Lombok's @Value produces a similar immutable class, but through an annotation processor and with getX()-style getters. Records are part of the language, so they need no library or IDE plugin, and they work with pattern matching. Lombok still offers extras such as builders (@Builder), which records don't have. For plain data carriers on Java 16+, records are the default choice.

Follow-up questions this topic invites — and their answers

Q: Are records truly immutable? A: Only shallowly. The fields are final and there are no setters, but a component that refers to a mutable object (a List, an array, a Date) can still be changed through that reference. Make them deeply immutable with defensive copies in the compact constructor (List.copyOf) and immutable component types.

Q: Can you override the generated accessor, equals or toString? A: Yes. Declare a method with the same signature and yours replaces the generated one. That's useful, for example, to mask a password in toString(). Keep the contract, though: an accessor should return the component's value, and equals/hashCode should stay consistent with each other.

Q: Why can't a record extend another class? A: A record implicitly extends java.lang.Record, and Java has single inheritance. The deeper reason is that a record's whole state must be described by its header, and inherited fields from a superclass would break that. Records can implement interfaces, which is how you share behaviour between them.

Q: Can a record be used as a Spring @ConfigurationProperties class? A: Yes, since Spring Boot 2.6. Properties are bound through the canonical constructor. That makes configuration immutable and validated at startup, which is a nice fit.

Q: What's the difference between the canonical constructor and a compact constructor? A: The canonical constructor has the full parameter list and assigns every field. You can write it explicitly if you need to. The compact constructor is shorthand for it with no parameter list: you write only the validation and normalization logic, and the compiler adds the field assignments at the end.

Next

Sealed Classes

AI Tutor

Lesson: Records

Quick actions

AI responses can be inaccurate. Verify critical information.