Custom Constraints
Write your own constraint annotation and ConstraintValidator, check fields against each other at class level, and compose existing constraints.
@Constraint(validatedBy = XValidator.class)
@Target(FIELD) @Retention(RUNTIME)
@interface X { String message() default "..."; ... }
class XValidator implements ConstraintValidator<X, String> {
public boolean isValid(String v, ...) { ... }
}Built-in constraints cover lengths and ranges. Rules from your domain, such as "a valid currency code" or "check-out after check-in", deserve a name of their own. A custom constraint has two parts: an annotation, and a class that implements ConstraintValidator.
The annotation
It must be retained at runtime, carry @Constraint(validatedBy = ...), and declare three attributes that Bean Validation requires: message, groups and payload. Leave any of them out and Hibernate Validator throws at the first validation, not at compile time.
@Constraint(validatedBy = CurrencyCodeValidator.class)
@Target({FIELD, METHOD, PARAMETER, TYPE_USE})
@Retention(RUNTIME)
public @interface CurrencyCode {
String message() default "must be an ISO 4217 currency code";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}The validator
isValid receives the value and returns true or false. Return true for null, following the convention of the built-in constraints, and let @NotNull decide whether the field is required. A validator that rejects null forces every field it is used on to be mandatory, which surprises the next person who uses it on an optional field.
Spring Boot's validator creates validators through the application context, so a validator can take Spring beans in its constructor. Keep that for cheap, in-memory checks. A validator that calls another service or the database adds latency to every request, runs again wherever the DTO is validated, and cannot give a guarantee anyway, because the data can change between the check and the write.
Class-level constraints
A rule that involves two fields goes on the type. The validator receives the whole object. By default the error is reported against the object rather than a field, which makes it hard for a form to highlight. Use ConstraintValidatorContext to disable the default violation and attach the message to the field the user should change.
Composing constraints
When the same three annotations keep appearing together, meta-annotate a new constraint with them and use @Constraint(validatedBy = {}). Every use then gets the same rules, and a change to the rule is one edit. Add @ReportAsSingleViolation if the client should see one message instead of one per rule.
Example
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.ElementType.TYPE_USE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import jakarta.validation.Constraint;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import jakarta.validation.Payload;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.math.BigDecimal;
import java.util.Currency;
import java.util.Set;
import java.util.stream.Collectors;
(validatedBy = CurrencyCodeValidator.class)
({FIELD, METHOD, PARAMETER, TYPE_USE})
(RUNTIME)
public @interface CurrencyCode {
String message() default "must be an ISO 4217 currency code";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
class CurrencyCodeValidator implements ConstraintValidator<CurrencyCode, String> {
private static final Set<String> CODES = Currency.getAvailableCurrencies().stream()
.map(Currency::getCurrencyCode)
.collect(Collectors.toUnmodifiableSet());
public boolean isValid(String value, ConstraintValidatorContext context) {
// null is someone else's job: pair with @NotBlank when the field is required.
return value == null || CODES.contains(value);
}
}
record PriceRequest(
("0.01") BigDecimal amount,
String currency) {
}
When to use it
- Accept only real ISO 4217 currency codes on a pricing endpoint.
- Reject a booking whose check-out date is not after its check-in date, with the error on checkOut.
- Give the SKU format a single name and use it across a dozen DTOs.
More examples
A class-level rule reported on a field
The validator sees the whole BookingRequest, so it can compare dates. Without the last three statements the error would be a global error with no field, and a client highlighting fields would have nowhere to show it. With them, the error path is checkOut and the default message from the annotation is reused.
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import jakarta.validation.Constraint;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import jakarta.validation.Payload;
import jakarta.validation.constraints.FutureOrPresent;
import jakarta.validation.constraints.NotNull;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.time.LocalDate;
@Constraint(validatedBy = StayDatesValidator.class)
@Target(TYPE)
@Retention(RUNTIME)
@interface ValidStayDates {
String message() default "must be after the check-in date";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@ValidStayDates
public record BookingRequest(
@NotNull @FutureOrPresent LocalDate checkIn,
@NotNull LocalDate checkOut,
int guests) {
}
class StayDatesValidator implements ConstraintValidator<ValidStayDates, BookingRequest> {
@Override
public boolean isValid(BookingRequest booking, ConstraintValidatorContext context) {
if (booking == null || booking.checkIn() == null || booking.checkOut() == null) {
return true;
}
if (booking.checkOut().isAfter(booking.checkIn())) {
return true;
}
// Attach the message to checkOut instead of the whole object.
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate())
.addPropertyNode("checkOut")
.addConstraintViolation();
return false;
}
}
Composing a constraint from existing ones
Every field annotated @Sku gets the same three rules, and changing the format later is one edit. validatedBy = {} means the annotation has no validator of its own; the meta-annotations do the work. @ReportAsSingleViolation collapses whatever failed into the one message declared here. This composed constraint includes @NotBlank, so unlike most constraints it does reject null.
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.ElementType.TYPE_USE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import jakarta.validation.ReportAsSingleViolation;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
@NotBlank
@Size(max = 12)
@Pattern(regexp = "[A-Z]{3}-\\d{4,8}")
@ReportAsSingleViolation
@Constraint(validatedBy = {})
@Target({FIELD, PARAMETER, TYPE_USE})
@Retention(RUNTIME)
public @interface Sku {
String message() default "must be a SKU such as ABC-1234";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
record StockAdjustment(@Sku String sku, int delta) {
}
A validator that uses a Spring bean
The validator is not a @Component itself; Spring's constraint validator factory creates it and autowires the constructor. That works for validators run by the Spring-managed validator (controllers, @Validated beans). If you build a Validator by hand with Validation.buildDefaultValidatorFactory(), there is no context and the constructor injection fails, so keep using the injected jakarta.validation.Validator bean.
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import jakarta.validation.Constraint;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import jakarta.validation.Payload;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.util.Locale;
import java.util.Set;
import org.springframework.stereotype.Component;
@Component
class ReservedNames {
private final Set<String> names = Set.of("admin", "root", "support", "billing", "api");
boolean isReserved(String username) {
return names.contains(username.toLowerCase(Locale.ROOT));
}
}
@Constraint(validatedBy = NotReservedValidator.class)
@Target({FIELD, PARAMETER})
@Retention(RUNTIME)
@interface NotReserved {
String message() default "is reserved";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class NotReservedValidator implements ConstraintValidator<NotReserved, String> {
private final ReservedNames reservedNames;
// Spring Boot's validator builds validators as beans, so constructor injection works.
public NotReservedValidator(ReservedNames reservedNames) {
this.reservedNames = reservedNames;
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
return value == null || !reservedNames.isReserved(value);
}
}
Discussion