Java records make DTOs and value objects concise and immutable. Learn how to use them correctly in Spring Boot - with Jackson, JPA, and validation - and avoid the common traps.
JOptimize Team
Java records, introduced as a stable feature in Java 16, are one of the most practically useful additions to the language in years. For Spring Boot developers, they're perfect for DTOs, command objects, and value types. But they also have real limitations - especially with JPA entities, Jackson deserialization, and Bean Validation - that trip up developers who treat them as a drop-in replacement for all classes.
// Before records - 15+ lines of boilerplate public class CreateOrderRequest { private final Long customerId; private final List<Long> productIds; private final String shippingAddress; public CreateOrderRequest(Long customerId, List<Long> productIds, String shippingAddress) { this.customerId = customerId; this.productIds = productIds; this.shippingAddress = shippingAddress; } public Long getCustomerId() { return customerId; } public List<Long> getProductIds() { return productIds; } public String getShippingAddress() { return shippingAddress; } // equals, hashCode, toString... } // After records - 1 line public record CreateOrderRequest(Long customerId, List<Long> productIds, String shippingAddress) {}
The compiler generates: constructor, getters (without get prefix), equals(), hashCode(), and toString() - all correctly, all immutable.
Records shine as Spring MVC request bodies and response objects:
// Request DTO public record CreateOrderRequest( @NotNull Long customerId, @NotEmpty List<Long> productIds, @NotBlank String shippingAddress ) {} // Response DTO public record OrderResponse( Long id, String status, BigDecimal total, LocalDateTime createdAt ) {} @RestController @RequestMapping("/orders") @RequiredArgsConstructor public class OrderController { @PostMapping public ResponseEntity<OrderResponse> create( @RequestBody @Valid CreateOrderRequest request) { OrderResponse response = orderService.create(request); return ResponseEntity.status(HttpStatus.CREATED).body(response); } }
Jackson deserialization gotcha: By default, Jackson needs either a @JsonCreator annotation or the -parameters compiler flag to deserialize into records. Spring Boot 2.7+ auto-configures this correctly, but older setups may fail with MismatchedInputException.
# If deserialization fails, add this compiler flag to pom.xml: # <compilerArg>-parameters</compilerArg> # Or add Jackson's parameter names module:
@Bean public Jackson2ObjectMapperBuilderCustomizer parameterNamesModule() { return builder -> builder.modules(new ParameterNamesModule()); }
Validation annotations work on record components:
public record UserRegistrationRequest( @NotBlank @Size(min = 3, max = 50) String username, @Email @NotBlank String email, @NotBlank @Size(min = 8) String password, @NotNull @Past LocalDate birthDate ) {}
For cross-field validation, use a custom class-level constraint:
@PasswordsMatch // Custom annotation public record ChangePasswordRequest( @NotBlank String currentPassword, @NotBlank @Size(min = 8) String newPassword, @NotBlank String confirmPassword ) {} @Constraint(validatedBy = PasswordsMatchValidator.class) @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface PasswordsMatch { String message() default "Passwords do not match"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; } public class PasswordsMatchValidator implements ConstraintValidator<PasswordsMatch, ChangePasswordRequest> { public boolean isValid(ChangePasswordRequest req, ConstraintValidatorContext ctx) { return req.newPassword().equals(req.confirmPassword()); } }
// THIS DOES NOT WORK - do not use records as JPA entities @Entity public record Order( // ? JPA requires mutable state, no-arg constructor @Id Long id, String status ) {}
JPA entities require:
Keep your @Entity classes as regular classes. Use records for DTOs only.
Records are implicitly final - they cannot extend other classes (except Object) and cannot be subclassed:
// Impossible - records are final public record AdminUser() extends User {} // ? Won't compile
For polymorphic DTOs, use sealed interfaces + records:
public sealed interface PaymentMethod permits CreditCardPayment, BankTransferPayment {} public record CreditCardPayment(String cardNumber, String cvv) implements PaymentMethod {} public record BankTransferPayment(String iban, String bic) implements PaymentMethod {}
Records support compact constructors for validation and normalization:
public record EmailAddress(String value) { // Compact constructor - runs before field assignment public EmailAddress { if (value == null || !value.contains("@")) { throw new IllegalArgumentException("Invalid email: " + value); } value = value.toLowerCase().trim(); // Normalize } } // Usage: EmailAddress email = new EmailAddress(" User@Example.COM "); System.out.println(email.value()); // user@example.com
getX() style - record accessors are field(), not getField(); frameworks expecting JavaBeans convention may failList<String> in a record is immutable at the reference level, but the list itself can still be mutated; use List.copyOf() in a compact constructorJava records are the right choice for DTOs, request/response objects, command objects, and value types in Spring Boot. They eliminate boilerplate and enforce immutability. Avoid them for JPA entities, classes that need inheritance, or anywhere a no-arg constructor is required. Use compact constructors for validation and normalization logic.
JOptimize detects mutable DTOs that should be records, records misused as JPA entities, and missing validation annotations on request objects.
Modernize your Spring Boot codebase with Java records - free scan, no configuration required.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.