Using Java Records for Immutable Data Transfer Objects
Learn how Java records reduce boilerplate for immutable DTOs, see a validation example, and understand where records are not suitable.
08 Jun 2026, 03:31 UTC

The Boilerplate Burden
Many data‑carrier classes in Java end up looking like a ritual: a few final fields, a constructor, getters, equals, hashCode and toString. IDEs can generate the code, but the noise still hides the intent when the class is merely a DTO, an event payload or a configuration snapshot.
Records, a standard language feature since Java 16, collapse that ceremony into a single declaration while guaranteeing immutability and structural equality. The trade‑off is that records are implicitly final and cannot be extended, so they are not suitable for mutable entities or frameworks that rely on subclassing.
What a Record Actually Gives You
A record declaration such as
public record UserDto(String id, String name, Instant createdAt) {}
produces:
- A canonical constructor that receives all components.
- Component accessors named exactly as the components:
id(),name(),createdAt()(nogetprefix). - Implementations of
equals,hashCodeandtoStringbased solely on component values. - An implicitly
finalclass that extendsjava.lang.Record.
All components are final. You cannot add instance fields beyond the declared ones, which makes the contract transparent: a record is a pure holder for its data.
Worked Example: Validated DTO with a Compact Constructor
Imagine a REST endpoint that receives a user registration payload. You want to enforce length limits and e‑mail format without writing a builder or setter‑heavy class. A compact constructor lets you place validation logic next to the data definition.
public record UserRegistration(
@NotBlank String username,
@Email String email,
@PastOrPresent Instant dob
) {
public UserRegistration {
if (username.length() > 50) {
throw new IllegalArgumentException("username too long");
}
// Bean Validation annotations handle email and dob
}
}
The compact constructor (no parameter list) runs after the canonical constructor assigns the components. Validation lives alongside the data, keeping the definition concise. Jackson can deserialize directly into UserRegistration because the canonical constructor matches JSON property names.
If you need a derived field—for example, a normalized lower‑case e‑mail—you can mutate the parameter inside the compact constructor:
public record UserRegistration(String username, String email, Instant dob) {
public UserRegistration {
email = email.toLowerCase(Locale.ROOT);
}
}
The assignment updates the local parameter before the implicit this.email = email step, centralising the normalisation.
Where Records Fit—and Where They Don't
Good fits
- API request and response bodies.
- Configuration objects read at startup.
- Event payloads in message‑driven systems.
- Read‑only projections from queries (e.g.,
record OrderSummary(UUID id, BigDecimal total, Instant placedAt) {}).
Avoid records for
- JPA entities: most providers expect mutable classes with a no‑arg constructor and field‑level access; records are
finaland lack setters. - Domain models with evolving state: an
Orderthat moves throughPLACED,SHIPPED,DELIVEREDneeds mutation or state‑specific subclasses—neither works with records. - Frameworks that rely on subclass proxies: older versions of Mockito or DI containers may struggle, though recent releases (Mockito 5+, Spring 6+) support records. Verify your stack.
Actionable Checklist
- Identify classes that are already effectively immutable data carriers (all fields
final, no behavior beyond accessors). - Confirm no framework requires a no‑arg constructor or setter injection on those classes.
- Replace the class with a record; run the test suite to catch reflection‑based code expecting
getX()methods. - Add compact constructors for validation or normalisation where needed.
- Update documentation: component accessors are
id(), notgetId().
If you are on Java 17 or 21 (the current LTS releases), records are fully supported. For Java 11 projects you must upgrade the language level before using records.
Closing: Make the Default Immutable
Start new DTOs, events and projections as records. Keep mutable domain entities as classes. The simple rule of thumb is: if the object's identity is its data, use a record; if its identity is its lifecycle, use a class. This eliminates most of the “should this be a record?” debates during code review.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.