Why architecture matters here

A record declaration is a claim, not a shorthand. record Point(int x, int y) {} says that an instance of this type is the pair (x, y), that nothing else is hiding behind it, and that any two instances carrying the same pair are the same value. From that one claim the compiler derives a constructor, accessors, equality and a printable form, and the language then forbids everything that would make the claim false. The design principle worth holding on to is that a record is a transparent carrier for its components. Every rule that looks arbitrary in isolation is that principle seen from a different angle.

Getting this wrong is what makes records feel disappointing. A team adopts them as a boilerplate-removal tool, discovers the accessor is x() and not getX(), that a component holding an ArrayList is still perfectly mutable, and that adding one field to a released record breaks every caller that compiled against it, and concludes the feature shipped half-finished. It did not. Those are consequences of transparency, and they were chosen.

What follows is records as a language feature: what the compiler emits, what it refuses, how records deconstruct, and where they stop being the right shape. Object layout, identity and flattening are a separate subject that belongs to value classes.

A record is a nominal tuple

A tuple is a positional product of values. Records are tuples that carry a name, which is the difference between them and a generic Pair<A,B>: Money(EUR, 4999) and Discount(EUR, 4999) have identical shapes and are unrelated types, so the compiler will not let you pass one where the other is expected. You get the compactness of a tuple without surrendering the type system.

public record Money(Currency currency, long minorUnits) {}

Money m = new Money(Currency.getInstance("EUR"), 4999);
m.currency();       // accessor named after the component, not getCurrency()
m.minorUnits();
m.equals(new Money(Currency.getInstance("EUR"), 4999));  // true
m.toString();       // Money[currency=EUR, minorUnits=4999]

The trade being made is encapsulation. An ordinary class chooses what to expose, may store its state in one form and publish it in another, and may change its representation later without touching a caller. A record forfeits all of that on purpose. Its component list is simultaneously the canonical constructor signature, the field set, the accessor set, the equality and hash contract, the serialized form and the deconstruction pattern — six APIs derived from one line. That is exactly why a record is cheap to write, and exactly why widening it afterwards is expensive.

Records are not value classes

These two are constantly conflated because both are described as "data classes", and they are orthogonal. Records are about API shape: the component list is the whole public truth about the object. Value classes are about identity and layout: an object that has no identity, so the runtime is free to copy it, inline it into its container and skip the header entirely.

A record today is an ordinary identity object. It has a header, it is allocated on the heap unless escape analysis manages to scalarise it, == on two equal records is false, and synchronized (someRecord) compiles and locks. Being implicitly final with final fields gives the JIT useful guarantees, but nothing about being a record causes flattening. Conversely, a value class need not be transparent at all: it can keep private fields and hand-written accessors and publish nothing about its representation.

The two axes compose rather than compete, which is why value record Point(int x, int y) {} is the idiom to expect once value classes land — transparent and identity-free. Until then, treat "record" as a statement about your API and read the value classes article for anything to do with memory layout.

Advertisement

What the compiler writes for you

From the component list the compiler derives a fixed set of members. A private final field per component. A canonical constructor whose parameter list is exactly the component list. A public accessor per component, named exactly like the component and returning its declared type — not getX(), because a record is not a bean. And equals, hashCode and toString, implemented as invokedynamic call sites bootstrapped by java.lang.runtime.ObjectMethods, so the emitted bytecode stays small no matter how many components you have.

Two more things are true of the class itself: it implicitly extends java.lang.Record and it is implicitly final. And the class file carries a Record attribute listing each component with its name, descriptor, generic signature and annotations. That attribute is the reflective surface: Class.isRecord() and Class.getRecordComponents(), which is how binding frameworks introspect records without any annotations from you.

You may override any generated member, and the compiler checks only the signature, never the meaning. This is the one hole in the transparency guarantee worth knowing about: the generated equals is specified in terms of the component values, while a record pattern is specified to invoke the accessor. An overridden accessor that returns anything other than its component therefore puts equality and pattern matching quietly out of sync with each other. Override accessors to defend a copy, never to change a value.

Java records — data-oriented programming + pattern matching + immutabilityconcise, safe data classes for modern JavaRecord declarationrecord R(A a, B b)Auto membersaccessors + equals + hashCode + toStringImmutabilityshallow + explicit deepCompact ctorvalidationPattern matchingdeconstructionSealed interfacesclosed hierarchiesSerializationno default clone; Serializable OKInteropbeans / frameworks / JSONRefactoringclass → recordBest practicesboundaries + DTO usageOps — annotations + generation + toolingmatchsealserializeinteroprefactoruseuseoperateoperate
Java records with pattern matching and interop surfaces.

The exact semantics of the generated equals

The generated equals first checks that the argument is an instance of the same record type — an exact check, since records are final and cannot have subtypes — and then compares component by component. The comparison rule per component is worth memorising:

  • Primitive components other than float and double are compared with ==.
  • float and double components use Float.compare / Double.compare semantics, matching what Float.valueOf(x).equals(...) does. So NaN equals NaN, and +0.0 does not equal -0.0. The first is what you want: with bare ==, a record containing a NaN would not even be equal to itself, and every hash-based lookup keyed on it would miss.
  • Reference components go through Objects.equals, so null components compare equal to each other and everything else defers to the component's own equals.

That last rule is where array components go wrong. record Key(byte[] bytes) {} compares its arrays by reference, so two keys wrapping byte-identical content are unequal, hashCode differs per instance, and toString prints [B@1b6d3586. Records with array components are almost always a defect; wrap the array in a List, or override all three members explicitly.

Two further cautions. The hashCode algorithm is deliberately unspecified — never persist it, shard on it, or send it across the wire; compute an explicit digest instead. And toString prints every component, so a record holding a password, token or PAN will leak it the first time anything interpolates the object into a log line. Override toString on any secret-bearing record.

Compact constructors - validation and normalization

The canonical constructor can be written three ways: left implicit, declared in full with the component list as its signature and explicit field assignments, or declared in compact form — a body with no parameter list, where the parameters are implicitly declared for you and the assignment of each parameter to its field is implicitly appended at the end.

The consequence of that implicit trailing assignment is the whole trick: in a compact constructor you cannot write this.name = ... (it will not compile), you reassign the parameter, and whatever the parameter holds when the body finishes is what gets stored. One construct therefore covers both validation and normalization.

public record DateRange(LocalDate start, LocalDate end) {
    public DateRange {                                  // compact form
        Objects.requireNonNull(start, "start");
        Objects.requireNonNull(end,   "end");
        if (end.isBefore(start))
            throw new IllegalArgumentException(start + " > " + end);
    }
}

public record Tag(String name) {
    public Tag {
        name = name.strip().toLowerCase(Locale.ROOT);    // reassign the PARAMETER
        if (name.isEmpty()) throw new IllegalArgumentException("blank tag");
    }
}

This is the correct place for invariants because it is the only door. Every instance goes through the canonical constructor: any additional constructor you declare must delegate to it with this(...) as its first statement, deserialization invokes it rather than bypassing it, and record patterns only read instances, they never build them. Enforce an invariant here and it holds everywhere, without a single defensive check in the rest of the codebase.

One constraint surprises people: the canonical constructor must be at least as accessible as the record itself. A public record cannot have a private canonical constructor, so the familiar "private constructor plus static factory" idiom is simply unavailable. If construction must be funnelled through a factory, publish an interface and keep the record non-public behind it, or do not use a record.

The restrictions, and why each one follows from transparency

The restriction list reads like arbitrary language trivia until you notice every entry is the same argument.

Implicitly final. A subclass could add state. Two instances would then be equal under the generated equals while genuinely differing, and toString would describe an object that is not the whole object. Transparency requires the component list be the entire story, which requires there be no subtype to add to it.

Implicitly extends java.lang.Record, so it can extend nothing else. Single inheritance is already spent, and an inherited field would be state outside the component list — the same violation from the other direction. Records may implement any number of interfaces, which is how they take part in sealed hierarchies.

No additional instance fields, and no instance initializer blocks. The same rule stated directly, plus the requirement that the canonical constructor stay the only door. Static fields and static initializers are unrestricted, so constants, caches and interned instances are fine.

Cannot be abstract; nested and local records are implicitly static. A non-static nested record would capture its enclosing instance, which is hidden state that no component describes.

What you can add is generous: static and instance methods, static fields, implemented interfaces, type parameters, and annotations on components. A component annotation propagates to the field, the accessor, the constructor parameter and the type use according to that annotation's own @Target, which is why validation annotations written directly on a component behave the way you would expect.

Advertisement

Shallow immutability and the defensive copy question

Record fields are final. Final means the reference cannot be reassigned; it says nothing about the object on the other end. record Order(String id, List<Item> items) {} is not immutable if the caller keeps a handle on the list it passed in. They mutate it, your "value object" changes underneath you, its hashCode changes with it, and any HashMap holding it as a key is now corrupt.

The compact constructor is the copy point, and List.copyOf / Map.copyOf / Set.copyOf do four jobs at once: they copy, they reject a null argument, they reject null elements, and they return an unmodifiable collection whose own copyOf is a no-op so re-wrapping costs nothing. Because the accessor then hands out that same unmodifiable collection, both directions close: callers cannot mutate what they gave you, and cannot mutate what they read back.

public record Order(String id, List<Item> items, Map<String,String> labels) {
    public Order {
        items  = List.copyOf(items);      // copy + null-hostile + unmodifiable
        labels = Map.copyOf(labels);
    }
    public Order withLabel(String k, String v) {      // hand-written "wither"
        var next = new LinkedHashMap<>(labels);
        next.put(k, v);
        return new Order(id, items, next);
    }
}

The cost is an allocation and a copy on every construction, which is real if the record is built millions of times a second on a hot path. The honest alternative there is a documented trusted-caller contract: acceptable inside a module boundary, wrong on a published API. The better move is upstream — choose immutable component types in the first place (List<String> over String[], Instant over Date) and the copy question mostly evaporates.

Note also that nothing generates a "wither". Changing one component means calling the constructor with all the others, as above. It is fine for three components and miserable for nine, which is one more reason wide records hurt.

Serialization - why records are the safer case

Ordinary Java serialization is dangerous for a specific, structural reason: deserializing a Serializable class allocates the instance without running any constructor and writes the fields straight out of the stream. Every invariant your constructor enforces is bypassed. That single fact is the origin of the whole readObject defensive-copy discipline, and of a long lineage of deserialization vulnerabilities.

Records close that hole by construction. The stream carries the component values; the runtime reads them and invokes the canonical constructor. Your validation runs. Your normalization runs. Your defensive copies happen. A hostile stream cannot hand you a DateRange whose end precedes its start, because there is no code path that builds one. The same argument applies to JSON and message binders that construct through the canonical constructor rather than reflectively poking fields.

The trade-off is that you lose control of the stream shape. readObject, writeObject and readObjectNoData are ignored for records; there is no hook for a custom encoding. writeReplace and readResolve do still apply, so serialization-proxy patterns remain available. Component values are matched by name rather than by stream position, and a component absent from the stream arrives as its default value — null or zero — which your canonical constructor will then reject if it validates, so schema drift fails loudly instead of silently.

One migration caveat: converting an existing serializable class into a record changes its serialized form. Old streams will not read into the new type. Do not refactor a class that is already on the wire or already in a persisted store.

Record deconstruction patterns

Deconstruction is what makes the positional constructor pay for itself. A record pattern names a record type and supplies a sub-pattern per component, in declaration order, all components or none. The compiler checks that shape against the component list, so renaming or reordering a component turns every matching site into a compile error rather than a silent behaviour change.

sealed interface Shape permits Circle, Rect {}
record Point(double x, double y) {}
record Circle(Point centre, double r) implements Shape {}
record Rect(Point lo, Point hi)       implements Shape {}

static double area(Shape s) {
    return switch (s) {
        case Circle(Point c, double r)  -> Math.PI * r * r;
        case Rect(Point(var x1, var y1),
                  Point(var x2, var y2)) -> Math.abs(x2 - x1) * Math.abs(y2 - y1);
    };
}

At run time the pattern tests the type and then calls the accessors, which is the mechanism behind two behaviours. First, an accessor that throws during deconstruction surfaces wrapped in a MatchException rather than escaping raw — another reason accessors should not contain logic. Second, patterns nest arbitrarily and var infers each component type, so a two-level structure destructures in one line, as above.

The null rule is the part that gets repeated wrongly, so state it precisely: a nested type pattern rejects null only when it is actually performing a narrowing test. Given record Box(Object o) {}, the pattern case Box(String s) does not match new Box(null), because String narrows Object and null fails that test. Given record Named(String s) {}, the pattern case Named(String s) does match new Named(null), because the pattern type is the component type and the test is unconditional. Use var when you want a component bound unconditionally regardless of null.

At the top level a record pattern never matches null, and switching on a null selector throws NullPointerException unless the switch has an explicit null label. Type patterns outside records, guards and switch syntax generally are their own subject and are not covered here.

Sealed interfaces plus records - algebraic data types in Java

A sealed interface enumerates a closed set of implementations; a record is a fixed product of components. Put them together and you have a sum of products — an algebraic data type — with a switch as the eliminator. Scala's sealed traits with case classes and Kotlin's sealed classes with data classes are the same construction under different names.

sealed interface Event {}
record Placed(String orderId,  Instant at, long amountMinor) implements Event {}
record Cancelled(String orderId, Instant at, String reason)  implements Event {}
record Refunded(String orderId, Instant at, long amountMinor) implements Event {}

static Ledger apply(Ledger l, Event e) {
    return switch (e) {                  // no default: a 4th Event breaks the build
        case Placed(var id, var at, var amt)    -> l.debit(id, amt, at);
        case Cancelled(var id, var at, var why) -> l.release(id, why, at);
        case Refunded(var id, var at, var amt)  -> l.credit(id, amt, at);
    };
}

The valuable property is the missing default. Because the compiler knows the permitted set, the switch is exhaustive without one; and when someone adds a fourth event type, every switch that omitted default fails to compile and hands you the exact list of sites to update. Writing default -> ... throws that away and buys nothing, so add it only when you genuinely want future cases absorbed silently.

Exhaustiveness is a compile-time computation, which matters under separate compilation: a switch that was exhaustive when compiled may not be at run time if the sealed hierarchy grew and the consumer was not rebuilt. The JVM fails such a switch rather than falling through silently, but the practical rule is to keep a sealed hierarchy and its consumers in one build unit.

Compared with the visitor pattern, which achieves the same exhaustiveness with far more ceremony, this arrangement puts operations outside the data, so adding an operation is a purely local change. The reverse is the cost: adding a case touches every switch. That is the expression problem, and it tells you when to choose this shape — a case set that is stable, an operation set that grows.

Records at the framework boundary

JavaBeans introspection looks for getX and setX. A record has neither, so anything strictly bean-driven — older property copiers, some template and expression engines, form binders, classic ORMs — sees a record as an object with zero properties. That is not a bug awaiting a patch: the bean protocol assumes mutable, no-arg-constructible objects, which is precisely the opposite of what a record is.

What works instead is the record-specific reflection surface. Because the class file's Record attribute carries component names, RecordComponent.getName() is available without compiling with -parameters. Modern JSON binders and constructor-binding configuration frameworks read getRecordComponents() and call the canonical constructor, which is why records bind with no annotations at all while ordinary constructor binding still needs the compiler flag.

Two hard limits follow from immutability and finality. Reflection cannot write a record's fields: setAccessible(true) followed by Field.set on a record component field throws, so tools that historically allocated an object and poked fields into it — some mocking frameworks, some deserializers, some ORM machinery — had to move to constructor invocation. And a record is final, so no subclass proxy can be generated for it; interface proxies still work, but the proxy is not the record.

The workable rule is that records belong on the edge of a framework — request and response bodies, configuration binding, query projections, message payloads, cache values — and not in the places where the framework needs to instantiate lazily, proxy, or mutate.

Where a record is the wrong choice

JPA entities. An entity needs a no-arg constructor, non-final fields for lazy loading and dirty checking, a non-final class for proxying, and identity semantics where equality means the same primary key rather than the same field values. A record fails every one of those. Records are excellent on the read side of the same system though: constructor-expression queries and DTO projections are exactly the shape they were designed for.

Anything with identity. Two customers with identical fields are not the same customer. A record declares that they are, and a Set will duly collapse them. Equality by state is the entire point of a record and the wrong semantic for an entity.

Anything mutable — a session, a connection wrapper, a builder, an accumulator, a cache entry with a hit counter. There is no partially-mutable record.

Anything whose component list will grow. Adding a component changes the canonical constructor signature. That is source-incompatible for every call site and binary-incompatible for anything already compiled against the old signature, which surfaces as a NoSuchMethodError at run time. A class with a builder absorbs a new optional field without touching a single caller. If you own both sides and rebuild together the growth is cheap; across a published API it is a breaking change every time.

Anything that needs to hide its representation — a type that stores epoch millis but publishes an Instant, or that stores more than it exposes. A record publishes its representation by definition, permanently.

Wide records. Eight positional arguments at a call site is unreadable, and two adjacent same-typed components will eventually be swapped by someone with no compiler to stop them. Group them into nested records or move to a builder.

One thing that is not a reason to avoid records: performance. They are ordinary final classes, the JIT treats them as such, and escape analysis scalarises short-lived ones exactly as it would any other small final class.

A record is a transparent carrier for its components: one declaration that is simultaneously the constructor, the fields, the accessors, the equality contract, the serialized form and the deconstruction pattern. Every restriction — final, no superclass, no extra instance fields — exists to keep that single claim true, and every payoff — correct equality for free, deserialization that cannot bypass your invariants, exhaustive switches over sealed hierarchies — falls out of it. Reach for a record when the type genuinely is its data. When it has identity, mutable state, or a component list that will keep growing, write a class.