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
4.3 KiB
2. Record validation, field-level and class-level
Prev: The jakarta.validation namespace | README | Next: What the defaults do not do
Source: ContactFormRecord.java,
EventBookingRecord.java,
DateRangeValid.java.
Test: ValidationScenariosTest.java.
Transcripts: docs/output/03-record-based-spam-rejected.txt,
docs/output/05-record-based-bad-date-range.txt,
docs/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:
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).
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 shows
the one thing that is not identical: the HTTP response body. See
the next chapter 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:
@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
dispatches on the runtime type and reads accessor methods that differ by naming convention only:
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 --
and a correctly-ordered pair is accepted:
docs/output/06-record-based-good-date-range.txt.
Going deeper
- Records themselves: Java Records (JEP 395) (rel="nofollow")
- Prev: The jakarta.validation namespace
- Next: What the defaults do not do