Validation & Exception Handling

Custom Validation Constraints

18 min Lesson 4 of 13

Custom Validation Constraints

Jakarta Bean Validation ships with a solid set of built-in constraints — @NotNull, @Size, @Pattern, @Email and a dozen others. They cover the common cases, but real-world domain rules are rarely this generic. A username must not contain spaces. A discount percentage cannot exceed the base price. A phone number must match a country-specific format. These rules cannot be expressed with any single built-in annotation, and stringing several of them together becomes fragile and hard to read. This is when you write a custom constraint.

A custom constraint in Jakarta Validation always has two parts: an annotation that you apply to fields or parameters, and a validator class that contains the logic. The annotation declares metadata; the validator does the work. Spring Boot wires them together automatically — no extra bean configuration is needed.

Anatomy of a Custom Constraint Annotation

Start by defining the annotation itself. Three meta-annotations from the Jakarta API are mandatory:

  • @Constraint(validatedBy = ...) — links the annotation to its validator class.
  • @Target — controls where the annotation can be placed (fields, method parameters, entire types, etc.).
  • @Retention(RUNTIME) — the annotation must be available at runtime for the validation engine to read it.

The annotation must also declare three attributes that the framework requires: message, groups, and payload. These have standard default values; you almost never override them at the annotation definition level.

package com.example.api.validation; import jakarta.validation.Constraint; import jakarta.validation.Payload; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Documented @Constraint(validatedBy = NoSpacesValidator.class) @Target({ ElementType.FIELD, ElementType.PARAMETER }) @Retention(RetentionPolicy.RUNTIME) public @interface NoSpaces { String message() default "must not contain spaces"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
Why @Documented? It is not required by the framework, but it causes the annotation to appear in generated Javadoc. This is worth including for any public API constraint so that users of your library see the rule on the field directly in the docs.

Writing the Validator Class

The validator class implements ConstraintValidator<A, T>, where A is your annotation type and T is the type of the value being validated. The interface has two methods:

  • initialize(A annotation) — called once when the validator is set up. Use it to read annotation attributes if your constraint is parameterised (e.g., a min/max value). Leave the body empty if there is nothing to read.
  • isValid(T value, ConstraintValidatorContext context) — called for each validation. Return true if the value is acceptable, false to fail validation.
package com.example.api.validation; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; public class NoSpacesValidator implements ConstraintValidator<NoSpaces, String> { @Override public void initialize(NoSpaces annotation) { // no attributes to read } @Override public boolean isValid(String value, ConstraintValidatorContext context) { // null is "valid" here — @NotNull handles the null case separately if (value == null) { return true; } return !value.contains(" "); } }
Treat null as valid in your isValid method. Jakarta Validation defines a clear separation of concerns: nullability is the responsibility of @NotNull (or @NotBlank for strings). If your custom validator also rejects nulls, you couple two concerns in one place and cannot independently control them on different fields. Return true for null and let @NotNull handle it when needed.

Applying the Constraint

Once the annotation and validator are in place, you use your custom constraint exactly like any built-in one. You can stack it with others:

public class RegistrationRequest { @NotBlank @Size(min = 3, max = 30) @NoSpaces(message = "Username must be a single word with no spaces") private String username; @NotBlank @Email private String email; // getters / setters }

And in the controller, @Valid triggers all constraints including your custom one:

@PostMapping("/register") public ResponseEntity<Void> register(@Valid @RequestBody RegistrationRequest request) { userService.register(request); return ResponseEntity.status(HttpStatus.CREATED).build(); }

Parameterised Constraints

The real power of custom constraints comes from making them configurable. Suppose you need to validate that a string value is one of a fixed set of allowed values — an "allowed values" constraint. You add attributes to your annotation and read them in initialize:

@Documented @Constraint(validatedBy = AllowedValuesValidator.class) @Target({ ElementType.FIELD, ElementType.PARAMETER }) @Retention(RetentionPolicy.RUNTIME) public @interface AllowedValues { String[] values(); // required attribute — no default String message() default "must be one of the allowed values"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
import java.util.Arrays; import java.util.Set; import java.util.stream.Collectors; public class AllowedValuesValidator implements ConstraintValidator<AllowedValues, String> { private Set<String> allowed; @Override public void initialize(AllowedValues annotation) { allowed = Arrays.stream(annotation.values()).collect(Collectors.toSet()); } @Override public boolean isValid(String value, ConstraintValidatorContext context) { if (value == null) return true; return allowed.contains(value); } }

Usage on a field, with a meaningful inline message:

@AllowedValues( values = { "STANDARD", "EXPRESS", "OVERNIGHT" }, message = "Shipping tier must be STANDARD, EXPRESS, or OVERNIGHT" ) private String shippingTier;

Class-Level Constraints

Sometimes validation must compare multiple fields — a classic example is "end date must be after start date". You cannot express this on a single field. Instead, target the whole class by setting @Target(ElementType.TYPE) and validate the object:

@Documented @Constraint(validatedBy = DateRangeValidator.class) @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface ValidDateRange { String message() default "End date must be after start date"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
public class DateRangeValidator implements ConstraintValidator<ValidDateRange, EventRequest> { @Override public boolean isValid(EventRequest request, ConstraintValidatorContext context) { if (request.getStartDate() == null || request.getEndDate() == null) return true; return request.getEndDate().isAfter(request.getStartDate()); } }
@ValidDateRange public class EventRequest { @NotNull private LocalDate startDate; @NotNull private LocalDate endDate; // getters / setters }
Class-level constraint errors are bound to the root object, not to a field. When the violation is reported, it will show up without a property path unless you explicitly add one inside isValid using context.buildConstraintViolationWithTemplate(...).addPropertyNode("endDate").addConstraintViolation(). This is important for REST APIs that map violations to JSON field paths, as covered in the next lesson.

Injecting Spring Beans into Validators

Because Spring Boot registers the LocalValidatorFactoryBean as the default validator, every ConstraintValidator implementation is a Spring-managed bean. That means you can inject services with @Autowired or constructor injection — useful when a constraint requires a database lookup, such as checking that a username is not already taken:

@Component public class UniqueUsernameValidator implements ConstraintValidator<UniqueUsername, String> { private final UserRepository userRepository; public UniqueUsernameValidator(UserRepository userRepository) { this.userRepository = userRepository; } @Override public boolean isValid(String username, ConstraintValidatorContext context) { if (username == null) return true; return !userRepository.existsByUsername(username); } }
Keep database-querying validators in mind for performance. Each call to isValid may execute a SQL query. For registration-style endpoints this is fine. For bulk operations or deeply nested object graphs, consider whether the check belongs in the validator or in the service layer where you have more control over query batching.

Summary

Building a custom constraint is straightforward once you know the three-part recipe: an annotation decorated with @Constraint, @Target, and @Retention(RUNTIME); a validator class implementing ConstraintValidator<A, T>; and a return true for nulls. Parameterised constraints add configurable attributes read in initialize. Class-level constraints validate object state across fields. Spring Boot makes validators full beans, so you can inject repositories or services when the rule requires it. In the next lesson you will see how violations from both built-in and custom constraints are translated into structured HTTP error responses.