Spring Boot’s validation framework is a powerful tool, but the real magic happens when you go beyond the built-in constraints and create your own.
Let’s say you have a User object and you need to ensure that a username field adheres to a specific format: it must start with "user_" followed by at least three alphanumeric characters. Spring Boot’s default @Pattern can handle this, but what if your validation logic becomes more complex, involving multiple fields or external data? That’s where custom constraint annotations shine.
Here’s a User class with a custom validation annotation:
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import jakarta.validation.Valid;
import java.util.List;
public class User {
@NotBlank(message = "Username cannot be blank")
@ValidUsername(message = "Username must start with 'user_' followed by at least 3 alphanumeric characters")
private String username;
@NotBlank(message = "Email cannot be blank")
// Assume a built-in email pattern for simplicity here
private String email;
@NotNull(message = "Address cannot be null")
@Valid
private Address address;
// Constructors, getters, and setters
public User() {}
public User(String username, String email, Address address) {
this.username = username;
this.email = email;
this.address = address;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public Address getAddress() {
return address;
}
public void setAddress(Address address) {
this.address = address;
}
}
Notice the @ValidUsername annotation on the username field. This is our custom annotation, and it’s declared using the standard Java Bean Validation API.
Now, let’s define the annotation itself and its corresponding validator:
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ValidUsernameValidator.class)
public @interface ValidUsername {
String message() default "Invalid username";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
And here’s the validator implementation:
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.regex.Pattern;
public class ValidUsernameValidator implements ConstraintValidator<ValidUsername, String> {
private static final Pattern USERNAME_PATTERN = Pattern.compile("^user_\\w{3,}$");
@Override
public void initialize(ValidUsername constraintAnnotation) {
// Initialization code if needed
}
@Override
public boolean isValid(String username, ConstraintValidatorContext context) {
if (username == null || username.isEmpty()) {
return true; // Let @NotBlank handle null/empty
}
return USERNAME_PATTERN.matcher(username).matches();
}
}
To make this work, you need to include the jakarta.validation-api and a validation engine like hibernate-validator in your Spring Boot project’s dependencies. If you’re using spring-boot-starter-web, you likely already have these.
When Spring Boot receives a request with a User object, it automatically triggers validation. If the username doesn’t match the pattern defined in ValidUsernameValidator, the validation fails, and an appropriate error is returned.
The core problem this solves is encapsulating complex or domain-specific validation logic into reusable, declarative annotations. Instead of scattering messy if statements throughout your service layer, you declare your validation intent directly on your model. This dramatically improves code readability and maintainability. The jakarta.validation API provides a standardized way to define constraints (@Constraint), specify what they apply to (@Target, @Retention), and link them to their validation logic (validatedBy). The ConstraintValidator interface is where the actual checking happens, receiving the annotated value and a context for reporting errors.
The most surprising thing about custom validation annotations is how seamlessly they integrate with Spring’s data binding and error handling. When a validation error occurs, Spring doesn’t just throw an exception; it populates a BindingResult object, which you can then use to return detailed error messages to the client, often in a structured JSON format. This means you don’t need to manually catch validation exceptions and map them to API responses – Spring handles it for you when you expose your controller endpoints correctly.
The next concept to explore is how to create custom constraints that span multiple fields of an object, rather than just validating a single field.