You have written the same forty-line data class a hundred times: private fields, a constructor, getters, equals, hashCode, and toString — and nothing else.

Every new field is a chance to forget one of those methods. Records are the language’s answer for that shape of type.

A record is a concise, immutable data carrier. You declare the components once in the header; the compiler generates the mechanical members. Reach for records when the type is its data — DTOs, value objects, event payloads — not when the type owns long-lived mutable behavior.

The problem records solve

Before records, a small immutable person type looked like this (and yes, teams really shipped this):

public final class Person {
    private final String name;
    private final int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() { return name; }
    public int getAge() { return age; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Person person)) return false;
        return age == person.age && Objects.equals(name, person.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }

    @Override
    public String toString() {
        return "Person[name=" + name + ", age=" + age + "]";
    }
}

With a record, the same intent is one line:

public record Person(String name, int age) {}

You still get a canonical constructor, accessors, and correct equals / hashCode / toString. Add a field later and those methods stay in sync — the classic “forgot to update equals after adding a field” bug disappears for this class of type.

When records shipped

ReleaseStatusSpec
Java 14First previewJEP 359
Java 15Second previewJEP 384
Java 16Standard featureJEP 395
Java 17 LTSAvailable on the LTS most teams run—

Use Java 16+. In practice most production codebases meet records on Java 17 LTS (or newer). No preview flag is required once you are on 16+.

Mental model

Think of a record as a transparent immutable data carrier:

  • The header lists every component — that list is the state.
  • Components become private final fields.
  • Accessors are named after the component: name(), not getName().
  • There are no setters.
  • The class is implicitly final and extends java.lang.Record.
  • It may implement interfaces; it may not extend another class.
FeatureBehavior
Declarationrecord Point(int x, int y) {}
FieldsAuto-generated private final per component
ConstructorCanonical constructor (and optional compact form)
Accessorsx(), y()
equals / hashCodeBased on all components
toStringPoint[x=1, y=2] style
InheritanceCannot extend a class; can implement interfaces
MutabilityImmutable surface — no setters

Basics in code

record Point(int x, int y) {}

Point p = new Point(3, 4);
System.out.println(p.x());           // 3
System.out.println(p);               // Point[x=3, y=4]
System.out.println(p.equals(new Point(3, 4))); // true

Records work with generics:

record Box<T>(T value) {}

Box<String> greeting = new Box<>("hello");

And they can implement interfaces:

interface Named {
    String name();
}

record User(String name, String email) implements Named {}

Customizing records

Compact constructors for validation

A compact constructor has no parameter list — the signature is implied from the components. It runs before the fields are assigned.

Use it to validate or normalize:

public record Email(String address) {
    public Email {
        Objects.requireNonNull(address, "address");
        address = address.trim().toLowerCase();
        if (!address.contains("@")) {
            throw new IllegalArgumentException("invalid email: " + address);
        }
    }
}

Assigning to address inside the compact constructor updates the value that will be stored in the field.

Extra constructors and factories

You can add overloaded constructors that delegate to the canonical one, and static factories:

public record Money(BigDecimal amount, Currency currency) {
    public Money {
        Objects.requireNonNull(amount, "amount");
        Objects.requireNonNull(currency, "currency");
        if (amount.scale() > currency.getDefaultFractionDigits()) {
            throw new IllegalArgumentException("scale too large for " + currency);
        }
        amount = amount.stripTrailingZeros();
    }

    public static Money usd(String amount) {
        return new Money(new BigDecimal(amount), Currency.getInstance("USD"));
    }

    public Money plus(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("currency mismatch");
        }
        return new Money(amount.add(other.amount), currency);
    }
}

Keep methods thin: derived values and small invariants are fine; dumping a service layer into the record is not.

Nested and local records

Records can be nested types, or declared inside a method when a short-lived projection helps a stream pipeline stay readable (see use cases below).

Note: Components can still hold mutable objects (List, arrays, mutable beans). The record’s fields are final, but callers can mutate what those fields point at unless you copy. That is shallow immutability — treat it as a footgun, not a feature.

public record Team(String name, List<String> members) {
    public Team {
        Objects.requireNonNull(name, "name");
        members = List.copyOf(members); // defensive copy → unmodifiable
    }
}

For arrays, copy on the way in (and again on the way out if you ever expose the array from an accessor you override).

Use cases

1. REST / API DTOs

Request and response bodies are pure data. Records cut noise and pair well with modern JSON libraries (Jackson 2.12+ binds records; Spring MVC can use them as @RequestBody / response types without getters named getX).

public record CreateOrderRequest(String customerId, List<LineItem> items) {
    public CreateOrderRequest {
        Objects.requireNonNull(customerId, "customerId");
        items = List.copyOf(items);
        if (items.isEmpty()) {
            throw new IllegalArgumentException("items required");
        }
    }
}

public record LineItem(String sku, int quantity) {
    public LineItem {
        if (quantity <= 0) {
            throw new IllegalArgumentException("quantity must be positive");
        }
    }
}

public record OrderResponse(String orderId, String status) {}

2. Domain value objects

Types like email, money, or an order id are defined by their value, not by identity. Records make that explicit:

public record OrderId(String value) {
    public OrderId {
        Objects.requireNonNull(value, "value");
        value = value.trim();
        if (value.isEmpty()) {
            throw new IllegalArgumentException("order id blank");
        }
    }

    @Override
    public String toString() {
        return value;
    }
}

3. Map keys and Set elements

Because equals and hashCode are derived from all components, records are natural composite keys. Why that pair has to stay in sync is equals and hashCode:

record Cell(int row, int col) {}

Map<Cell, String> board = new HashMap<>();
board.put(new Cell(0, 0), "X");
board.put(new Cell(0, 1), "O");

System.out.println(board.get(new Cell(0, 0))); // X

Note: Do not put a mutable component inside a key unless that component’s equality is stable. Prefer immutable components for anything that lands in a HashMap or HashSet.

4. Local records in stream pipelines

When a pipeline needs an intermediate tuple, a local record beats AbstractMap.SimpleEntry or a cryptic Object[]:

List<String> topSkuLabels(List<Order> orders) {
    record SkuQty(String sku, int qty) {}

    return orders.stream()
            .flatMap(o -> o.items().stream())
            .map(i -> new SkuQty(i.sku(), i.quantity()))
            .collect(Collectors.groupingBy(
                    SkuQty::sku,
                    Collectors.summingInt(SkuQty::qty)))
            .entrySet().stream()
            .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
            .limit(5)
            .map(e -> e.getKey() + "=" + e.getValue())
            .toList();
}

5. Sealed types and pattern matching (Java 21)

Records compose cleanly with sealed interfaces. On Java 21, switch + record patterns give exhaustive, compiler-checked handling:

sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}

static double area(Shape shape) {
    return switch (shape) {
        case Circle(var r) -> Math.PI * r * r;
        case Rectangle(var w, var h) -> w * h;
    };
}

Add a new permitted type later and every incomplete switch becomes a compile error until you handle it. That is a strong reason to model closed domain variants as sealed + records.

When not to use records

Skip records when:

  • JPA / Hibernate entities — ORMs typically want a no-arg constructor, mutable fields, and proxy-friendly types. Prefer a mutable entity (or a mapped projection) and map to a record at the API boundary.
  • Long-lived mutable state — builders that accumulate fields, session objects, or aggregates that change over time belong in ordinary classes.
  • Inheritance hierarchies of behavior — records cannot extend a class; share behavior via interfaces or composition.
  • “God” data bags — stuffing services, I/O, or large business workflows into a record fights the design. Keep records as carriers; put orchestration elsewhere.

Frameworks that still demand JavaBean setters are a smell for that boundary, not a reason to avoid records everywhere. Prefer an adapter or a library version that understands records.

Records vs Lombok

RecordsLombok @Value / @Data
DependencyLanguage feature (Java 16+)Annotation processor
ImmutabilityBuilt-in (shallow)@Value yes; @Data mutable
Accessorsname()Usually getName()
Equals / hashCodeAlways from componentsGenerated; easy to drift if mixed with hand-written code
Best fitTransparent data carriersGradual migration, bean conventions, mutable DTOs

Prefer records for new immutable carriers on Java 16+. Keep Lombok where you still need mutable beans or a large legacy surface that expects getX() everywhere — or migrate those call sites deliberately.

Cheat sheet

record Name(Type component, ...) {}

Java 16+ (JEP 395); common on Java 17 LTS
Accessors: component() — not getComponent()
Validate / normalize in a compact constructor
Copy mutable components (List.copyOf, array clone)
Good: DTOs, VOs, events, map keys, local stream tuples
Avoid: JPA entities, mutable aggregates, deep inheritance

Do:

  • Treat the header as the full state of the type.
  • Validate early in a compact constructor.
  • Pair with sealed interfaces when the set of variants is closed.

Don’t:

  • Expose a live mutable List or array from a record.
  • Use records as a substitute for an entity framework model.
  • Hide important business processes inside record methods.

Wrap-up

Records remove the ceremony around immutable data without giving up correct equality or a clear toString. They became a standard language feature in Java 16 (JEP 395) and are the default choice on Java 17+ for DTOs, value objects, event payloads, and other pure carriers.

Start with the one-liner form. Add a compact constructor when invariants matter. Reach for sealed types and pattern matching when the domain is a closed set of shapes. Keep mutable, identity-driven, or ORM-backed types as ordinary classes — and map them to records at the edges where immutability pays off.

On Spring Boot 3, that boundary mapping is the whole point of a REST API: see REST API with Boot 3 + Records as DTOs.

Next optional step in the series Seal the variants, then dispatch with an exhaustive pattern switch. Sealed Classes + Pattern Switch: Exhaustive Code Without instanceof Soup