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.
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:
- Modeling data with the simplest possible aggregate (a record).
- Modeling alternatives with sealed hierarchies instead of flags or subclass sprawl.
- 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:
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.