# 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":"spammy@email.com","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)