Use Java Records for Immutable Data Carriers
Java records reduce boilerplate for DTOs and domain models, but their fixed component list creates API trade-offs you should evaluate before adoption.
03 Oct 2026, 12:25 UTC

The problem: mutable DTOs that change under you
A typical Java Data Transfer Object (DTO) is a plain class with private fields, public getters, and public setters. Because setters exist, any layer that receives the object can change its state. A service might adjust a discount field after the controller has validated it, or a background job might alter a status string that the UI already displayed. These silent mutations make bugs hard to trace and often force teams to write defensive copies everywhere.
The useful takeaway: if your object exists only to carry data between layers, it should probably be immutable. Java records, finalized in Java 16, give you a concise syntax for exactly that kind of data carrier.
What a record gives you
A record declaration such as public record OrderDto(Long orderId, String status) creates a final class with private final fields, a canonical constructor, and accessor methods named after the components—orderId() and status(), not getOrderId(). The compiler also generates equals, hashCode, and toString based on those components.
You cannot add instance fields beyond the components, and there are no setters. Once constructed, the record's state cannot change through its public API. That immutability is the core engineering benefit: callers can pass the object around without worrying that another thread or layer will mutate it.
Worked example: an order DTO with validation
Here is a record for an order summary that validates its inputs in a compact constructor. The compact constructor runs after the canonical constructor and lets you reject invalid data before the object exists.
import java.math.BigDecimal;
public record OrderDto(Long orderId, String status, BigDecimal total) {
public OrderDto {
if (orderId == null || orderId <= 0) {
throw new IllegalArgumentException("orderId must be positive");
}
if (status == null || status.isBlank()) {
throw new IllegalArgumentException("status is required");
}
if (total == null || total.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("total cannot be negative");
}
}
}
Save this as OrderDto.java and compile it in a terminal with a JDK 16 or later installation:
javac --release 16 OrderDto.java
This command needs no special permissions beyond read access to the source file and write access to the output directory. If the JDK version is older than 16, the compiler rejects record syntax with an error such as "records are not supported."
To confirm what the compiler generated, inspect the bytecode:
javap -p OrderDto.class
Look for public accessor methods named orderId, status, and total, plus equals, hashCode, and toString. You should not see setter methods.
You can also test behavior interactively with jshell --release 16. Construct a record, call its accessors, and confirm that two records with the same components are equal. Do not paste production secrets into jshell; it keeps input in memory for the session.
Where records create friction
- Component list is public API. Because accessors are named after components, renaming or reordering components breaks callers that use those methods. Treat the component list as a stable contract.
- No extra state. You cannot declare non-component instance fields. If you need derived or cached values, compute them in accessor methods or use a regular class.
- Serialization differs. Records use a serialization mechanism based on components rather than traditional serializable fields. Test with your existing frameworks, such as JSON libraries or remote-procedure clients, before rolling out to production.
- Not for mutable entities. Domain objects that change over their lifecycle—an order that moves from
NEWtoSHIPPED—are poor candidates for records. Model those as regular classes with controlled mutator methods.
How to decide and verify
- Check the toolchain. Run
java -versionandjavac --version. Both should report 16 or higher. - Compile a candidate record and inspect it with
javap -p. Verify accessors match your component names and no setters exist. - Write a unit test that constructs the record and asserts accessor return values. Attempting to call a setter should fail at compile time, which is the check you want.
- Run your serialization path—JSON mapping, HTTP session storage, or RPC—and confirm the record round-trips correctly.
Use records when the data should not change
Choose a record for DTOs, API response bodies, search results, and read-only domain summaries. Choose a regular class when the object needs mutable state, complex invariants that cannot be enforced at construction, or a freely evolving internal API. Records reduce boilerplate, but their fixed component list means you should adopt them with the same care you would apply to any public interface.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.