For years, a "value class" in Java meant fifty lines of getters, a constructor, equals, hashCode, and toString — most of it generated by an IDE and skimmed past in review. Records, introduced in JDK 16, collapse that boilerplate into one line and, more importantly, nudge the language toward a different way of modeling programs: describe your data plainly, then write functions that operate on it, instead of wrapping every piece of state in a class that also decides how to behave.

What a Record Actually Buys You

A record declaration generates a canonical constructor, private final fields, public accessors, and correct equals, hashCode, and toString implementations, all derived from the component list.

java
public record Money(BigDecimal amount, Currency currency) {

    public Money {
        if (amount.scale() > currency.getDefaultFractionDigits()) {
            throw new IllegalArgumentException("Too many decimal places for " + currency);
        }
    }

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

That compact constructor — the public Money { ... } block with no parameter list — runs before field assignment and is the idiomatic place to validate invariants. You still get to add methods like plus; a record is not limited to pure data holders, it just can't hold extra fields beyond its components, and every field is implicitly final.

Data-Oriented Programming, Not Just Less Typing

The real shift is philosophical. Object-oriented modeling often bundles data and behavior into the same hierarchy, which forces you to design class trees around anticipated behavior even for data that's fundamentally just... data. Data-oriented programming — a term Brian Goetz has used to describe where records and sealed types are pushing Java — favors:

  1. Modeling data with the simplest possible aggregate (a record).
  2. Modeling alternatives with sealed hierarchies instead of flags or subclass sprawl.
  3. Keeping behavior in separate functions or services that pattern-match over the data, rather than spreading it across virtual methods.

Combined with pattern matching for switch, this looks like:

java
sealed interface PaymentEvent permits Authorized, Captured, Refunded {}
record Authorized(String txId, Money amount) implements PaymentEvent {}
record Captured(String txId, Money amount) implements PaymentEvent {}
record Refunded(String txId, Money amount, String reason) implements PaymentEvent {}

static String describe(PaymentEvent event) {
    return switch (event) {
        case Authorized a  -> "Authorized " + a.amount();
        case Captured c    -> "Captured " + c.amount();
        case Refunded r    -> "Refunded " + r.amount() + " (" + r.reason() + ")";
    };
}

There's no inheritance, no virtual dispatch to trace through — the shape of the data and the logic that handles it are both fully visible in one place.

Where Records Fall Short

Records are not a replacement for every class. A few practical limits worth knowing:

Situation Record fits? Why
Immutable DTO / value object Yes Exactly what records are for
JPA entity No Entities need mutability and no-arg constructors
Builder-style object with many optional fields Sometimes Canonical constructors get unwieldy past ~5 fields
Class needing inheritance No Records are implicitly final and can't extend a class

For JPA and other frameworks that expect mutable, proxyable entities, stick with regular classes. For everything that's genuinely a snapshot of data passed between layers — API responses, event payloads, configuration — records should be the default, not the exception. If you find yourself writing a manual equals next to a record, something has gone wrong upstream.