Custom Validation Constraints
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.
@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. Returntrueif the value is acceptable,falseto fail validation.
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:
And in the controller, @Valid triggers all constraints including your custom one:
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:
Usage on a field, with a meaningful inline message:
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:
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:
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.