Records as DTOs
Java records (stable since Java 16) are the ideal vehicle for DTOs. A record is a transparent, immutable carrier for a fixed set of values — exactly what a DTO is — and the compiler generates the constructor, accessors, equals, hashCode, and toString for you. One line replaces dozens of lines of boilerplate, and the result is immutable and thread-safe by default.
A record as a DTO
Declaring a DTO becomes a one-liner. The record’s components become its fields and accessors.
public record ProductResponse(
Long id,
String name,
BigDecimal price,
String categoryName
) {}
This generates a canonical constructor ProductResponse(Long, String, BigDecimal, String), accessors named id(), name(), etc. (no get prefix), and value-based equals/hashCode. See Java Records for the full language-level details.
Compact canonical constructors
To validate or normalise on construction, add a compact canonical constructor — no parameter list, just the body. It runs before the fields are assigned.
public record CreateProductRequest(String name, BigDecimal price, Long categoryId) {
public CreateProductRequest {
Objects.requireNonNull(name, "name is required");
if (price != null && price.signum() < 0) {
throw new IllegalArgumentException("price must not be negative");
}
name = name.trim(); // normalise before assignment
}
}
Reassigning name inside the compact constructor updates what gets stored — a clean place to trim or defensively copy.
Validation annotations on record components
Bean Validation works on records: place jakarta.validation constraints directly on the components. Combined with @Valid in the controller, Spring validates them automatically before your handler runs.
public record CreateProductRequest(
@NotBlank String name,
@NotNull @Positive BigDecimal price,
@NotNull Long categoryId
) {}
@PostMapping
public ResponseEntity<ProductResponse> create(
@Valid @RequestBody CreateProductRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(service.create(request));
}
A failed constraint yields an automatic 400 Bad Request. See Validating the request body and common constraints.
Note: If a constraint annotation can’t target a record component directly, place it explicitly with
@field:/ElementType.PARAMETERsemantics. In practice the standard Hibernate Validator constraints work on record components out of the box.
Jackson serialization
Jackson (Spring Boot 3.5 bundles a version with full record support) serializes and deserializes records without any extra configuration. On deserialization it calls the canonical constructor, so your compact-constructor validation still runs.
{
"id": 42,
"name": "Mechanical Keyboard",
"price": 89.99,
"categoryName": "Peripherals"
}
Rename a JSON field with @JsonProperty on the component:
public record ProductResponse(
Long id,
@JsonProperty("product_name") String name,
BigDecimal price
) {}
Tip: Because records are immutable, there is no risk of a handler accidentally mutating an incoming DTO — a subtle but real safety win over mutable classes with setters.
Records vs classes for DTOs
| Aspect | Record DTO | Class DTO (e.g. Lombok @Data) |
|---|---|---|
| Boilerplate | None — compiler-generated | Lombok or hand-written |
| Mutability | Immutable | Usually mutable (setters) |
| Accessors | name() | getName() |
equals/hashCode | Value-based, automatic | Lombok @EqualsAndHashCode |
| Inheritance | Cannot extend a class | Supports extension |
| Partial/builder updates | Awkward (re-create) | Easy with @Builder / setters |
| Library fit | MapStruct: excellent | ModelMapper: excellent |
When records beat classes
Reach for a record when:
- The DTO is a fixed, immutable set of values — the common case for request and response DTOs.
- You want value semantics (
equals/hashCode) for free. - You’re mapping with MapStruct, which targets the canonical constructor cleanly.
Prefer a class when:
- You need mutability — e.g. ModelMapper populating fields via setters, or a builder for many optional fields.
- You need to extend a base DTO (records are implicitly
final). - A framework requires a no-arg constructor and bean-style setters that a record can’t provide.
Warning: A record’s accessor is
name(), notgetName(). Tools and templates expecting JavaBean getters (some older serializers or expression languages) may need configuration. Jackson and Bean Validation in current Spring Boot handle records natively.
For most Spring Boot DTOs in 2026, records are the default and classes the exception.