Custom Validation in Spring Boot: Beyond the Basics!

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 our validation needs get a little… funkier? What if we need to check if a String contains specific keywords, or ensure two fields have a particular relationship?

That’s where Spring’s custom validation truly shines! While the built-in validators are fantastic for common scenarios, understanding how to roll your own opens up a whole new world of robust data handling. In this post, we’re going to dive into creating custom validators, making our Spring Boot apps even smarter.


Why Custom Validation?

Imagine a scenario where you’re building a user registration form. You might have these requirements:

  • Password Complexity: Must contain at least one uppercase, one lowercase, one digit, and one special character.
  • Username Uniqueness (Client-side): While server-side uniqueness is a given, you might want to prevent common or reserved usernames.
  • Date Range Check: An ‘end date’ must always be after a ‘start date’.

These go beyond what @NotNull or @Min can handle. That’s our cue for custom validation!


Our Example: Validating a “Contact Us” Form

Let’s consider a simple “Contact Us” form where a user provides their email and a message. We want to ensure that the message, if it contains the word “spam” (we all get those!), isn’t too short. A legitimate message containing “spam” should still be substantial enough to be considered valid.

Step 1: The Data Transfer Object (DTO)

First, let’s define our ContactForm DTO. This will hold the user’s input. We’re starting with our standard checks.

package com.ankurm.blog.dto;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;

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.")
    private String message;

    // Getters and Setters (omitted for brevity)
    // ...
}

Step 2: Define Our Custom Annotation

Our custom validation needs an annotation to tag the fields or classes it applies to. Let’s create @SpamMessageCheck. This is where the custom magic begins!

package com.ankurm.blog.validator;

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Documented
@Constraint(validatedBy = SpamMessageValidator.class) // <-- This links our annotation to its validator logic
@Target({FIELD}) // This annotation can be used on fields
@Retention(RUNTIME)
public @interface SpamMessageCheck {

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

    Class[] groups() default {};

    Class[] payload() default {};
}

The @Constraint(validatedBy = ...) line is the most important part—it points directly to the class that contains the validation rules.

Step 3: Implement the Validator Logic

Now for the brains of the operationSpamMessageValidator. This class implements ConstraintValidator<SpamMessageCheck, String> (our annotation and the type of the field we’re validating).

package com.ankurm.blog.validator;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

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
        }

        // Our custom logic: if message contains 'spam' (case-insensitive),
        // it MUST be at least 50 characters long.
        if (message.toLowerCase().contains("spam")) {
            return message.length() >= 50;
        }

        return true; // No 'spam' detected, so it's valid based on this specific rule
    }

    @Override
    public void initialize(SpamMessageCheck constraintAnnotation) {
        // We don't need any special initialization here, but you could
        // grab config values from your annotation if needed!
    }
}

Step 4: Apply the Custom Annotation to Our DTO

Time to put it all together! We’ll add our new @SpamMessageCheck to the message field in ContactForm.

package com.ankurm.blog.dto;

import com.ankurm.blog.validator.SpamMessageCheck; // <-- Don't forget to import!
// ... other imports

public class ContactForm {
    // ... email field

    @NotBlank(message = "Message cannot be empty!")
    @Size(min = 10, message = "Message must be at least 10 characters long.")
    @SpamMessageCheck // <-- Our custom validator, stacked with the others!
    private String message;

    // ... Getters and Setters
}

Step 5: Testing It Out with a Spring Boot Controller

The final step is to use @Valid on our controller method parameter to automatically trigger all our validators (both standard and custom).

package com.ankurm.blog.controller;

import com.ankurm.blog.dto.ContactForm;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.Valid;
import java.util.Map;
import java.util.stream.Collectors;

@RestController
public class ContactController {

    @PostMapping("/contact")
    public ResponseEntity<?> submitContactForm(@Valid @RequestBody ContactForm contactForm,
                                                  BindingResult bindingResult) {

        if (bindingResult.hasErrors()) {
            // A neat way to map all validation errors to a simple response Map
            Map<String, String> errors = bindingResult.getFieldErrors().stream()
                    .collect(Collectors.toMap(
                            fieldError -> fieldError.getField(),
                            fieldError -> fieldError.getDefaultMessage()
                    ));
            return ResponseEntity.badRequest().body(errors);
        }

        // If validation passes, process the form
        return ResponseEntity.ok("Contact form submitted successfully!");
    }
}

Let’s Run and Test!

Here’s how our custom rule works when hitting the /contact endpoint:

Scenario 1: “Spam” Message, too Short

POST /contact 
{
    "email": "[email protected]",
    "message": "This is spam."
}

Output: 400 Bad Request – {“message”:”Message contains ‘spam’ and must be at least 50 characters long.”} (Our custom validator fires!)

Scenario 2: Valid Message (no “spam,” long enough for @Size)

POST /contact 
{
    "email": "[email protected]",
    "message": "This is a legitimate message about an issue I'm facing."
}

Output: 200 OK – Contact form submitted successfully! (Passes all checks)


Beyond Field-Level Validation (Class-Level Validation)

What if your validation depends on multiple fields within the same DTO? For example, “end date must be after start date.”

You’d create a custom annotation, say @DateRangeValid, and apply it to the class level of your DTO:

@DateRangeValid // Applied to the class!
public class EventBooking {
    private LocalDate startDate;
    private LocalDate endDate;
    // ...
}

Your ConstraintValidator would then be of type ConstraintValidator<DateRangeValid, EventBooking>, and its isValid method would receive the entire EventBooking object:

public class DateRangeValidator implements ConstraintValidator<DateRangeValid, EventBooking> {
    @Override
    public boolean isValid(EventBooking booking, ConstraintValidatorContext context) {
        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!


Wrapping Up

Custom validation in Spring Boot is a super flexible and powerful feature. It allows you to define precise business rules and enforce them at the very first layer of your application. By combining built-in annotations with your own custom ones, you can create a truly robust and user-friendly experience.

Remember to keep your custom validators focused on a single responsibility for readability and maintainability. Happy validating!

Got any cool custom validation scenarios you’ve tackled? Share them in the comments below!

Leave a Reply

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