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

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