Skip to content
Spring Boot sb dto 3 min read

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.PARAMETER semantics. 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

AspectRecord DTOClass DTO (e.g. Lombok @Data)
BoilerplateNone — compiler-generatedLombok or hand-written
MutabilityImmutableUsually mutable (setters)
Accessorsname()getName()
equals/hashCodeValue-based, automaticLombok @EqualsAndHashCode
InheritanceCannot extend a classSupports extension
Partial/builder updatesAwkward (re-create)Easy with @Builder / setters
Library fitMapStruct: excellentModelMapper: 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(), not getName(). 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.

Last updated June 13, 2026
Was this helpful?