Back to Blog
javarecordsspring-bootdtojava-21best-practices

Java Records in Spring Boot: Best Practices and Pitfalls (2026)

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.

J

JOptimize Team

May 23, 2026· 7 min read

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.


What Records Give You

// 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 as Request/Response DTOs

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()); }

Records with Bean Validation

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()); } }

What Records Are NOT Good For

JPA Entities

// 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:

  • A no-arg constructor (records don't have one by default)
  • Mutable fields (records are immutable)
  • Proxy generation (CGLIB can't subclass records)

Keep your @Entity classes as regular classes. Use records for DTOs only.

Inheritance

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 {}

Compact Constructors for Validation

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

Common Mistakes to Avoid

  • Using records as JPA entities - won't work; JPA requires mutable classes with no-arg constructors
  • Accessing record fields with getX() style - record accessors are field(), not getField(); frameworks expecting JavaBeans convention may fail
  • Mutable collections in records - List<String> in a record is immutable at the reference level, but the list itself can still be mutated; use List.copyOf() in a compact constructor
  • Assuming all frameworks support records - older versions of MapStruct, ModelMapper, and similar tools may not support records; check compatibility first

Summary

Java 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.


Find DTO Anti-Patterns in Your Spring Boot Project

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.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.