Skip to main content

Custom Validation in Spring Boot: Beyond the Basics!

A custom Bean Validation constraint built from scratch on Spring Boot 4.1.1 and Jakarta Validation 3.1: the javax→jakarta namespace fix Boot 3 already required, field- and class-level constraints on Java records, and what spring.mvc.problemdetails.enabled does and does not fix.

We’ve all been there — building a Spring Boot application, slapping on a @NotBlank or @Size annotation, and feeling pretty good about our data integrity. But what happens when validation needs get a little… funkier? What if you need to check that a string contains specific keywords, or that two fields have a particular relationship to each other? That’s where a custom validation constraint comes in. This post builds one from scratch, on Spring Boot 4.1.1 and Jakarta Validation 3.1 — and along the way corrects something the original version of this article got wrong: it used the pre-Jakarta javax.validation package, which has not been on a Spring Boot 3+ application’s classpath since Boot 3.0 shipped in December 2022. Every example below uses jakarta.validation, and every claim about what Bean Validation 3.1 actually changed is checked against the real jars this application loads, not read off a migration guide.
Versions used in this post, verified with mvn dependency:tree against a throwaway spring-boot-starter-parent:4.1.1 project, then confirmed a second way by reading the actual jar manifests at test time.

Spring Boot 4.1.1 · Spring Framework 7.0.9 · Hibernate Validator 9.1.3.Final (the reference implementation of Jakarta Validation 3.1) · jakarta.validation-api 3.1.1 · JDK 25
All the code below lives in a real, tested companion module: custom-validation in the spring-boot-demo repository. Every curl transcript quoted is a JUnit assertion, not a hand-typed example.

What built-in validation can’t do

Imagine a “Contact Us” form: an email address and a message. @NotBlank and @Size handle “is it present” and “is it long enough” easily. But suppose you want a specific business rule: if the message contains the word “spam” (everyone gets those), it must be substantial — at least 50 characters — to be treated as a legitimate message rather than rejected outright. No built-in annotation expresses that. This is the smallest example that needs a rule Bean Validation itself doesn’t ship, which makes it a good one to build from scratch. A second, different kind of gap: validating the *relationship* between two fields, like an event booking where the end date must be after the start date. No single-field annotation can see two fields at once. That needs a different mechanism — a class-level constraint — covered later in this post.

Building a field-level custom constraint

A custom constraint has three parts: the annotation itself, the validator class that implements the rule, and the field it’s applied to. Here’s the DTO first, so the destination is clear before the machinery: Source: ContactForm.java
public class ContactForm {

    @NotBlank(message = "Email cannot be empty!")
    @Email(message = "Please provide a valid email address.")
    private String email;

    @NotBlank(message = "Message cannot be empty!")
    @Size(min = 10, message = "Message must be at least 10 characters long.")
    @SpamMessageCheck
    private String message;

    // getters and setters
}
Note the package these annotations come from: jakarta.validation.constraints, not javax.validation.constraints. If your own codebase still imports the latter, it did not actually survive the Boot 2-to-3 upgrade — Boot 3 moved its entire dependency tree to the Jakarta EE 9 namespace, and `javax.validation` classes simply are not on a Boot 3+ classpath. @SpamMessageCheck is the custom part. Its annotation declaration links it to the class that implements the rule: Source: SpamMessageCheck.java
@Documented
@Constraint(validatedBy = SpamMessageValidator.class)
@Target({FIELD, PARAMETER, RECORD_COMPONENT})
@Retention(RUNTIME)
public @interface SpamMessageCheck {

    String message() default "Message contains 'spam' and must be at least 50 characters long.";

    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
Notice RECORD_COMPONENT in @Target. Built-in constraints like @NotBlank already declare it, which is why they “just work” on a record field without anyone thinking about it. A hand-rolled constraint needs the same element type added deliberately — leave it off and the annotation still compiles fine on a class field, but cannot be placed on a record component at all. That’s a compile error, not a silent no-op, and it’s exactly what this post’s record-validation section below needs.
The validator itself is where the actual rule lives — a plain class implementing ConstraintValidator: Source: SpamMessageValidator.java
public class SpamMessageValidator implements ConstraintValidator<SpamMessageCheck, String> {

    @Override
    public boolean isValid(String message, ConstraintValidatorContext context) {
        if (message == null || message.trim().isEmpty()) {
            return true; // let @NotBlank handle empty/null messages
        }
        if (message.toLowerCase().contains("spam")) {
            return message.length() >= 50;
        }
        return true;
    }
}
And a controller that triggers validation with @Valid, collecting field errors from BindingResult into a response body: Source: ContactController.java
@PostMapping("/contact")
public ResponseEntity<?> submitContactForm(@Valid @RequestBody ContactForm contactForm,
                                              BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
        Map<String, String> errors = bindingResult.getFieldErrors().stream()
                .collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
        return ResponseEntity.badRequest().body(errors);
    }
    return ResponseEntity.ok("Contact form submitted successfully!");
}
Run it against a short “spam” message and the custom rule fires: Output: 01-class-based-spam-rejected.txt
$ curl -s -X POST localhost:8080/contact \
     -H 'Content-Type: application/json' \
     -d '{"email":"[email protected]","message":"This is spam."}'

HTTP status: 400
Body: {"message":"Message contains 'spam' and must be at least 50 characters long."}
A longer message with the same word passes, because it clears the 50-character bar: Output: 02-class-based-valid-accepted.txt
HTTP status: 200
Body: Contact form submitted successfully!

The same constraint on a Java record

Jakarta Validation 3.1 — the spec version Boot 4.1.1 pins — is the first revision to explicitly clarify how record components are validated. Before 3.1, the spec was silent on the question; in practice implementations mostly did the sensible thing, but “mostly” and “specified” are different guarantees. Here’s the identical set of constraints from ContactForm, moved onto a record: Source: ContactFormRecord.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
) {
}
A constraint on a record component behaves exactly like the equivalent constraint on a field: same validation, same message, same cascading under @Valid. The interesting part isn’t the validation — it’s what happens to the HTTP response when it fails, because the controller signature for a record has nowhere convenient to put a BindingResult.

What the defaults do not do: an empty error body

This surprised me while building the test suite, so it’s worth walking through rather than just stating. ContactController#submitContactFormRecord has no BindingResult parameter, so a failing constraint throws MethodArgumentNotValidException instead of populating a result object Spring can hand back to you. The assumption going in was that Spring’s default handling would still produce some readable body. It does not: Output: 03-record-based-spam-rejected.txt
$ curl -s -i -X POST localhost:8080/contact-record \
     -H 'Content-Type: application/json' \
     -d '{"email":"[email protected]","message":"This is spam."}'

HTTP status: 400
Body: '' (empty!)
An Accept: application/json header does not change this — checked directly rather than assumed, in 03b-record-based-spam-rejected-accept-json.txt. Nothing is being negotiated because nothing is being written. The property that changes this is spring.mvc.problemdetails.enabled=true, which already existed in Spring Boot 3 and is unrelated to Bean Validation itself — it turns on RFC 9457 ProblemDetail responses for framework-thrown MVC exceptions: Output: 07-problemdetails-enabled-record-rejected.txt
HTTP status: 400
Content-Type: application/problem+json
Body: {"detail":"Invalid request content.","instance":"/contact-record","status":400,"title":"Bad Request"}
Progress, but read it closely. There is now a body, but no mention of “spam,” no field name — just the generic RFC 9457 fields. Boot’s default MethodArgumentNotValidException-to-ProblemDetail mapping does not populate per-field messages for you. Getting the same detail the class-based endpoint hand-rolls from bindingResult.getFieldErrors() needs a custom @ExceptionHandler (or a ResponseEntityExceptionHandler override) that reads the exception’s own BindingResult — it still carries one internally, even though the controller signature doesn’t expose it — and writes the field errors into the ProblemDetail‘s properties map yourself. Turning the property on is not, by itself, a drop-in replacement for BindingResult-based error reporting.

Class-level validation: rules that span two fields

Back to the second gap from the top of this post: an “end date must be after start date” rule needs to see both fields at once, which means the constraint has to target the whole class, not one field. Define the annotation at the type level: Source: EventBooking.java
@DateRangeValid
public class EventBooking {

    @NotNull(message = "startDate is required")
    private LocalDate startDate;

    @NotNull(message = "endDate is required")
    private LocalDate endDate;

    // getters and setters
}
The validator’s isValid now receives the whole object instead of one field’s value: Source: DateRangeValidator.java
if (value instanceof EventBooking booking) {
    if (booking.getStartDate() == null || booking.getEndDate() == null) {
        return true; // let @NotNull handle nulls
    }
    return booking.getEndDate().isAfter(booking.getStartDate());
}
This pattern is incredibly powerful for complex business rules, and it survives the move to a record with only a naming-convention change — a class-level constraint on a record goes on the record’s type declaration exactly as it would on a class, and the validator reads booking.startDate() instead of booking.getStartDate(): Source: EventBookingRecord.java
@DateRangeValid
public record EventBookingRecord(
        @NotNull(message = "startDate is required") LocalDate startDate,
        @NotNull(message = "endDate is required") LocalDate endDate
) {
}
Both shapes reject a bad range the same way: Output: 05-record-based-bad-date-range.txt
$ curl -s -X POST localhost:8080/booking-record \
     -H 'Content-Type: application/json' \
     -d '{"startDate":"2026-05-10","endDate":"2026-05-01"}'

HTTP status: 400
And a correctly-ordered pair is accepted, as in 06-record-based-good-date-range.txt.

Should you write your own constraint?

Reach for a custom constraint when the rule is reusable and declarative — something you’d want to slap on a field or class and forget about. For a one-off check that only matters in a single service method, plain imperative code in that method is often clearer than the ceremony of an annotation, a validator class, and wiring the two together. Custom constraints earn their keep when the same rule shows up on more than one DTO, or when you want validation errors to surface through the same BindingResult/ProblemDetail machinery as everything else, automatically, at the framework boundary.
Custom validation in Spring Boot remains a flexible way to enforce precise business rules at the first layer of the application, on both classes and records, under Jakarta Validation 3.1. Keep each validator focused on one responsibility, decide upfront whether you need spring.mvc.problemdetails.enabled and a custom exception handler to get useful error bodies back to record-based endpoints, and the pattern from this post covers field-level and cross-field rules equally well.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.