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 operation: SpamMessageValidator. 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!