Safeguard
Application Security•

How do Java records improve an application, and what are the limits?

Java records, final in JDK 16, replace boilerplate for immutable data carriers. How they work, how to validate input, and the serialization rules to know.

Safeguard Security Team
4 min read

Java records are classes that act as transparent carriers for immutable data. Declare the components once and the compiler gives you the constructor, accessors, equals, hashCode and toString. They became a permanent language feature in JDK 16 under JEP 395, and they cut boilerplate and mutation bugs for data transfer objects and value types.

What does a record look like?

public record Transfer(String accountId, long cents, String currency) { }

That one line declares private final fields, a canonical constructor, accessor methods named accountId(), cents() and currency(), and the value based equals, hashCode and toString. JEP 395 describes records as nominal tuples: the name and the shape of the data are the whole point.

Records were proposed in JEP 359 and previewed in JDK 14, refined by JEP 384 in JDK 15, and finalized in JDK 16.

Why does immutability help?

A record's fields are final, so once an instance exists, nothing can change it. That removes a class of bugs where one thread or one layer edits an object that another layer assumes is stable. It also makes instances safe to share and to use as map keys, because the hash code cannot drift.

One caveat: shallow immutability. If a component is itself a mutable list or array, the record holds a reference to it and callers can still change the contents. Copy defensively in the constructor, for example with List.copyOf, when the data matters.

How do you validate input in a record?

Use a compact constructor. It has no parameter list, runs before the fields are assigned, and lets you check or normalize values. The JEP notes that assigning to the instance fields in a compact constructor body is a compile time error, so validation reads cleanly.

public record Transfer(String accountId, long cents, String currency) {
    public Transfer {
        if (cents <= 0) throw new IllegalArgumentException("amount must be positive");
        currency = currency.toUpperCase();
    }
}

Because every path that builds a record goes through its canonical constructor, the check cannot be skipped by a caller who forgets it. That is a security benefit: invalid states are unrepresentable if the constructor refuses them.

What changes for serialization?

JEP 395 states that records can be serialized and deserialized, but the process cannot be customized with writeObject, readObject, readObjectNoData, writeExternal or readExternal. The record's components govern serialization, and the canonical constructor governs deserialization.

This is good news for defenders. Native Java deserialization of ordinary classes can bypass constructors, which has been a long standing source of bugs. For records, the constructor runs, so your validation runs. It does not make deserializing untrusted data safe by itself, since other types in the object graph may not be records, so keep treating untrusted serialized input with care.

JSON libraries have their own record support and their own versions. Check that your library version handles records and still calls the canonical constructor.

What can a record not do?

A record cannot have an extends clause, because its superclass is always java.lang.Record, and it is implicitly final. It cannot declare extra instance fields beyond its components. It can implement interfaces, declare static members and add methods. If you need mutable state or inheritance, use an ordinary class.

A constructed example

A payments service has a mutable TransferRequest class with eight setters. A bug appears where a logging filter rewrites the currency field after validation. The team replaces the class with a record, moves the checks into the compact constructor, and the filter now has to build a new object to change anything. The failure moves from a silent data change to a compile error at the call site.

How to check or apply this

  1. Find classes that only hold data: DTOs, API request and response bodies, message payloads, configuration values.
  2. Convert them to records where inheritance and mutation are not needed.
  3. Put validation and normalization in the compact constructor.
  4. Copy mutable components, such as lists and arrays, in the constructor.
  5. Confirm your JDK is 16 or later, and that frameworks and serializers in your stack support records.
  6. Add tests that construct records with bad values and expect rejection.
  7. Keep scanning dependencies. Records fix your own data classes, not flaws in libraries.

Quick check

Are records fully immutable?

Their fields cannot be reassigned, but a field that holds a mutable object can still be changed through that object. Make defensive copies for such components.

Which Java version made records permanent?

JDK 16, through JEP 395. They were previews in JDK 14 and 15.

Sources

Last reviewed: 18 October 2026.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.