Files
spring-boot-demo/custom-validation/docs/02-record-validation.md
T
Claude e4b5636f7c Add custom-validation, etag-caching, restclient-basic-auth: Boot 4.1 API pass
Three companion modules verifying and rewriting the Boot 4.1.1 / Framework
7.0.9 story for three older articles: the javax->jakarta.validation namespace
fix plus Jakarta Validation 3.1 record-validation clarification, ETag/
conditional-request APIs re-verified unchanged plus the starter rename, and
RestTemplate Basic Auth rebuilt on RestClient with the exchange() trap called
out. 19 real passing tests generate every transcript quoted from the three
companion articles.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EQNA6DJ9VgCtW6zhCE8Xud
2026-09-19 10:17:09 +00:00

96 lines
4.3 KiB
Markdown

# 2. Record validation, field-level and class-level
[Prev: The jakarta.validation namespace](01-jakarta-namespace-and-bean-validation-3-1.md) | [README](../README.md) | Next: [What the defaults do not do](03-what-the-defaults-do-not-do.md)
Source: [`ContactFormRecord.java`](../src/main/java/com/ankurm/customvalidation/dto/ContactFormRecord.java),
[`EventBookingRecord.java`](../src/main/java/com/ankurm/customvalidation/dto/EventBookingRecord.java),
[`DateRangeValid.java`](../src/main/java/com/ankurm/customvalidation/validator/DateRangeValid.java).
Test: [`ValidationScenariosTest.java`](../src/test/java/com/ankurm/customvalidation/ValidationScenariosTest.java).
Transcripts: [`docs/output/03-record-based-spam-rejected.txt`](output/03-record-based-spam-rejected.txt),
[`docs/output/05-record-based-bad-date-range.txt`](output/05-record-based-bad-date-range.txt),
[`docs/output/06-record-based-good-date-range.txt`](output/06-record-based-good-date-range.txt).
## Field-level: a constraint on a record component
`ContactFormRecord` carries the same three constraints as the class-based `ContactForm` --
`@NotBlank`, `@Email`, `@SpamMessageCheck` -- placed directly on record components instead of
fields:
```java
public record ContactFormRecord(
@NotBlank(message = "Email cannot be empty!")
@Email(message = "Please provide a valid email address.")
String email,
@NotBlank(message = "Message cannot be empty!")
@Size(min = 10, message = "Message must be at least 10 characters long.")
@SpamMessageCheck
String message
) {
}
```
This only compiles because `@SpamMessageCheck`'s `@Target` includes `RECORD_COMPONENT` (see
[`SpamMessageCheck.java`](../src/main/java/com/ankurm/customvalidation/validator/SpamMessageCheck.java)).
Forget that element type on a custom constraint and the annotation still compiles fine on a class
field -- it simply cannot be placed on a record component at all, a compile error rather than a
silent no-op. Built-in constraints like `@NotBlank` already declare `RECORD_COMPONENT`, which is
why they worked on a hand-rolled record before this module ever touched the topic; a homemade
constraint needs the same declaration deliberately added.
Rejection behaves identically to the class-based version at the validation layer -- same
constraint, same message:
```
POST /contact-record
{"email":"[email protected]","message":"This is spam."}
```
[`docs/output/03-record-based-spam-rejected.txt`](output/03-record-based-spam-rejected.txt) shows
the one thing that is *not* identical: the HTTP response body. See
[the next chapter](03-what-the-defaults-do-not-do.md) for why.
## Class-level (cross-field): a constraint on the record's type
`EventBookingRecord` carries `@DateRangeValid` on the record's type declaration -- the same
position a class-level constraint occupies on an ordinary class:
```java
@DateRangeValid
public record EventBookingRecord(
@NotNull(message = "startDate is required") LocalDate startDate,
@NotNull(message = "endDate is required") LocalDate endDate
) {
}
```
The one code change a cross-field validator needs to support both shapes is in the validator
itself, not the annotation -- [`DateRangeValidator`](../src/main/java/com/ankurm/customvalidation/validator/DateRangeValidator.java)
dispatches on the runtime type and reads accessor methods that differ by naming convention only:
```java
if (value instanceof EventBooking booking) {
return booking.getEndDate().isAfter(booking.getStartDate());
}
if (value instanceof EventBookingRecord booking) {
return booking.endDate().isAfter(booking.startDate()); // record accessors, no "get" prefix
}
```
Both shapes reject the same bad input the same way:
```
POST /booking-record
{"startDate":"2026-05-10","endDate":"2026-05-01"}
```
HTTP 400 -- [`docs/output/05-record-based-bad-date-range.txt`](output/05-record-based-bad-date-range.txt) --
and a correctly-ordered pair is accepted:
[`docs/output/06-record-based-good-date-range.txt`](output/06-record-based-good-date-range.txt).
## Going deeper
- Records themselves: [Java Records (JEP 395)](https://openjdk.org/jeps/395) (rel="nofollow")
- Prev: [The jakarta.validation namespace](01-jakarta-namespace-and-bean-validation-3-1.md)
- Next: [What the defaults do not do](03-what-the-defaults-do-not-do.md)