Avaje Validator
Discord | Source | API Docs | Issues | Releases |
---|---|---|---|---|
Discord | Github | Javadoc | Github |
Reflection-free POJO validation via apt source code generation.
A light (~120kb + generated code) source code generation style alternative to Hibernate Validation. (code generation
vs reflection)
- Annotate java classes with
@Valid
(or use@ImportValidPojo
for types we "don't own" or can't annotate) - Supports Avaje/Jakarta/Javax Constraint Annotations
- Validation Group Support
- Composable Contraints
- Inherited Contraints
- Cascaded Validation
- Adding/Modify constraints on 3rd Party classes
Quick Start
1. Add avaje-validator dependencies.
<dependency>
<groupId>io.avaje</groupId>
<artifactId>avaje-validator</artifactId>
<version>${avaje.validator.version}</version>
</dependency>
<!-- Alternatively can use Jakarta/Javax Constraints-->
<dependency>
<groupId>io.avaje</groupId>
<artifactId>avaje-validator-constraints</artifactId>
<version>${avaje.validator.version}</version>
</dependency>
<!-- If using spring:
<dependency>
<groupId>io.avaje</groupId>
<artifactId>avaje-validator-spring-starter</artifactId>
<version>${avaje.validator.version}</version>
</dependency>
-->
2. Add the annotation processor to your pom.
<!-- Annotation processors -->
<dependency>
<groupId>io.avaje</groupId>
<artifactId>avaje-validator-generator</artifactId>
<version>${avaje.validator.version}</version>
<scope>provided</scope>
<optional>true</optional>
</dependency>
2a. JDK 23+
In JDK 23+, annotation processors are disabled by default, so we need to add a compiler property to re-enable.
<properties>
<maven.compiler.proc>full</maven.compiler.proc>
</properties>
3. Add @Valid
onto types we want to serialize.
The avaje-validator-generator annotation processor will generate a ValidationAdapter as java source code for
each type annotated with @Valid
.
These will be automatically registered with validator using a service loader mechanism.
For types we cannot annotate with @Valid
we can instead use @ImportValidPojo
or @Mixin
to modify validation behavior of external classes.
@Valid
public class RingedCity {
@NotBlank
private String street;
@NotEmpty(message="must not be empty")
private List<@NotBlank(message="{message.bundle.key}") String> judges; // message will be interpolated from bundle
@Valid //cascaded validation
@NotNull(groups=Sunlight.class) //groups
private DarkEater dragon;
//add getters/setters
}
Also works with records:
@Valid
public record RingedCity(
@NotBlank String street,
List<@NotBlank(message="{message.bundle.key}") String> judges,
@Valid @NotNull(groups=Sunlight.class) DarkEater dragon
) {}
4. Validate your POJO
// build using defaults
Validator validator = Validator.builder().build();
Customer customer = ...;
// will throw a `ConstraintViolationException` containing all the failed constraint violations
validator.validate(customer);
// validate with explicit locale for error message lookup
validator.validate(customer, Locale.ENGLISH);
// validate with groups
validator.validate(customer, Locale.ENGLISH, Group1.class);
Java Module Setup
If using java modules, in the module-info.java
we need to:
- Add a requires clause for io.avaje.validator
- Add a provides clause for io.avaje.validator.validator.GeneratedComponent
Example module-info
import io.avaje.validation.Validator.GeneratedComponent;
import io.avaje.validation.spi.ValidationExtension;
module org.example {
requires io.avaje.validator;
// you must define the fully qualified class name of generated classes. If you use an import statement, compilation will fail
provides ValidationExtension with org.example.validator.GeneratedComponent;
//if running using jlink/jpackage application images with avaje-inject add:
//requires io.avaje.validator.plugin;
//if running using jlink/jpackage application images with both avaje-http and inject add:
//requires io.avaje.validator.http;
}
In the example above, org.example.validator.GeneratedComponent
is generated code typically found in
target/generated-sources/annotations
.
Avaje/Spring Integration
When used with Avaje Inject or the Spring Framework, a default Validator
and AOPMethodValidator
instance will be
provided. The following properties can be added to configure the construction of the default instance.
validation.failFast //enable fail fast mode
validation.resourcebundle.names // name of ResourceBundle files to load
validation.locale.default //default locale to use (defaults to Locale.getDefault())
validation.locale.addedLocales //Additional Locales this validator should support
validation.temporal.tolerance.value //temporal tolerance value
validation.temporal.tolerance.chronoUnit //What ChronoUnit enum value to use for temporal tolerance (default MILLIS)
ValidatorCustomizer
We can create a class that implements ValidatorCustomizer
that can be loaded via
ServiceLoader
while the builder is constructing a
Validator
instance.
public final class Customizer implements ValidatorCustomizer {
@Override
public void customize(Builder builder) {
builder.failFast(true);
}
}
Declaring and Validating Bean Constraints
Types annotated with @Valid
are picked up by the avaje-validator-generator at compile time and an
adapter is generated to perform validations.
Field Validation
Constraints can be expressed by annotating a field of a class with an annotation.
@Valid
public class Car {
@NotNull
private String manufacturer;
@AssertTrue
private boolean isRegistered;
//getters...
}
Getter Validation
It is also possible to annotate methods that have no arguments to validate the return type.
@Valid
public class Car {
private String manufacturer;
private boolean isRegistered;
@NotNull
public String getManufacturer() {
return manufacturer;
}
@AssertTrue
public boolean isRegistered() {
return isRegistered;
}
}
@Nullable
When a field/method is marked with any form of @Nullable
, constraints will only take effect when the
field/method result is not null.
Container element constraints
It is possible to specify constraints directly on the type argument of a parameterized type: these constraints are called container element constraints.
Avaje Validator supports container element constraints specified on the following standard Java containers:
- implementations of
java.util.Iterable
(e.g. Lists, Sets), - implementations of
java.util.Map
, with support for keys and values,
@Valid
public class Ship {
@Valid
private Map<@NotBlank String, @Valid @NotNull CrewMate> crew;
@Valid
private List<@NotBlank String> tasks;
//getters...
}
Optional element constraints
Optional and their variants work slightly differently than regular container types. The values are automatically unwrapped, so constraints must be placed the the field itself instead of the container.
@Valid
public record CurseBearer(
@NotBlank(message = "It'll happen to you too") Optional<String> name, //the value contained within is validated
@Positive OptionalInt estus,
@Positive(message = "You Died") OptionalLong souls,
@Positive(message = "vigor check failed") OptionalDouble vigor) {}
Class-level constraints
Last but not least, a constraint can also be placed on the class level. In this case not a single property is subject of the validation but the complete object. Class-level constraints are useful if the validation depends on a correlation between several properties of an object.
The below version of the Ship
class has the two attributes crew and imposters and
it should
be ensured that the list of imposters is proportional to the amount of crewmates. For that purpose the
@BalancedGame
constraint is added on the class level. The validator of that constraint has access to
the
complete Ship
object, allowing to compare the fields and validate correctly.
@Valid
@BalancedGame
public class Ship {
private List<CrewMate> crew;
private List<Imposters> imposters;
//...
}
Cascaded Validation
Avaje Validator supports validating complete object graphs. To do so, just annotate a field or property
representing
a reference to another object with @Valid
@Valid
public class Ship {
@Valid // an adapter will be generated for Crewmate as well
@NotNull
private Crewmate mate;
//...
}
class Crewmate {
@NotNull
private String name;
@AssertFalse
private boolean sus;
//...
}
Inherited Constraints
When a class implements an interface or extends another class, all constraint annotations declared on the super-type apply in the same manner as the constraints specified on the class itself. To make things clearer, let's have a look at the following example:
class Car {
private String manufacturer;
@NotNull
public String getManufacturer() {
return manufacturer;
}
//...
}
class RentalCar extends Car {
private String rentalStation;
@NotNull
public String getRentalStation() {
return rentalStation;
}
//...
}
Here the class RentalCar
is a subclass of Car
and adds the property
rentalStation
. If an instance of RentalCar
is
validated, not only the @NotNull
constraint on rentalStation
is evaluated, but also the
constraint on manufacturer
from the parent class.
The same would be true, if Car
was not a superclass but an interface implemented by
RentalCar
.
Constraint annotations are aggregated if methods are overridden. So if RentalCar
overrode the
getManufacturer()
method from Car
, any constraints annotated at the overriding method
would be
evaluated in addition to the @NotNull
constraint from the superclass.
@Mixin
Say we want to override the validation behavior on a class we can't modify. (For example, a class in an external
project/dependency). We can use @Mixin
to add/override constraint annotations on the specified type.
public class Swallow {
private String species;
@Positive
private int avgSpeed;
@AssertTrue
private boolean isLaden;
//getters/setters...
}
Given the above class, we can use the @Mixin
annotation on an abstract class to effectively
add/override constraint annotations.
@MixIn(Swallow.class)
@EntryRiddleCheck //class constraints can be added as well
public abstract class SwallowMixin {
//Add NotBlank
@NotBlank("species shouldn't be null")
String species;
//adding the target field without any annotations disables the constraints
boolean isLaden;
}
Generated Code: (click to expand)
Given the above mixin, the SwallowValidationAdapter becomes:
@Generated("avaje-validator-generator")
public final class SwallowValidationAdapter implements ValidationAdapter<Swallow> {
private final ValidationAdapter<String> speciesValidationAdapter;
private final ValidationAdapter.Primitive avgSpeedValidationAdapter;
private final ValidationAdapter<Swallow> swallowValidationAdapter;
public SwallowValidationAdapter(ValidationContext ctx) {
this.speciesValidationAdapter =
ctx.<String>adapter(NotBlank.class, Map.of("max",0, "message","species shouldn\'t be null", "_type","String"));
this.avgSpeedValidationAdapter =
ctx.<Integer>adapter(Positive.class, Map.of("message","{avaje.Positive.message}", "_type","Integer"))
.primitive();
this.swallowValidationAdapter =
ctx.<Swallow>adapter(EntryRiddleCheck.class, Map.of());
}
@Override
public boolean validate(Swallow value, ValidationRequest request, String field) {
if (field != null) {
request.pushPath(field);
}
var _$species = value.getSpecies();
speciesValidationAdapter.validate(_$species, request, "species");
var _$avgSpeed = value.getAvgSpeed();
avgSpeedValidationAdapter.validate(_$avgSpeed, request, "avgSpeed");
if (!request.hasViolations()) {
swallowValidationAdapter.validate(value, request, field);
}
if (field != null) {
request.popPath();
}
return true;
}
}
Method Validation
Constraints can not only be applied to POJOs and their properties, but also to the parameters and return values of the methods of Java types.
@ValidMethod
Adding @ValidMethod
to a method will generate classes that contain adapters for the methods
parameters/return types. These classes can be wired by a JSR-330 compliant DI library for use in AOP to execute
the
validations.
Avaje Validator has an integration with Avaje Inject to provide a default AOP method validator for bean classes. So when used with avaje inject, the below method will be proxied and validated.
@Singleton
public class GraphService {
@ValidMethod
@PositiveOrZero
public long calculate(@Positive int x, @Negative int x){
...
}
}
Generated code
@Generated
@Singleton
public final class TestParamProvider implements MethodAdapterProvider {
@Override
public Method method() throws Exception {
return MethodTest.class.getDeclaredMethod("test", List.class,int.class,String.class);
}
@Override
public List<ValidationAdapter<Object>> paramAdapters(ValidationContext ctx) {
return List.of(
ctx.<Object>adapter(Positive.class, Map.of("groups",Set.of(), "message","{io.avaje.validation.constraints.Positive.message}")),
ctx.<Object>adapter(Negative.class, Map.of("groups",Set.of(), "message","{io.avaje.validation.constraints.Negative.message}")));
}
@Override
public ValidationAdapter<Object> returnAdapter(ValidationContext ctx) {
return ctx.<Object>adapter(PositiveOrZero.class, Map.of("groups",Set.of(), "message","{io.avaje.validation.constraints.message}"));
}
}
Constraint Error Messages
Declaring Messages
Constraint violation messages are initially retrieved from the message annotation value. Each constraint defines its default message value using the message. At declaration time, the default value can be overridden with a specific value as shown below:
@Valid
public class Car {
@NotNull(message = "The manufacturer name must not be null")
private String manufacturer;
}
ResourceBundle Loading
If the message value begins and ends with brackets, avaje will check the configured ResourceBundles
using the bracket contents. If any bundle contains an entry for a given message parameter, the messeage parameter
will be
replaced with the corresponding value from the bundle. This step will be executed recursively in case the
replaced value begins/ends with brackets.
The resource bundle is expected to be provided by the application
developer, e.g. by adding a file named ValidationMessages.properties
to the classpath and registering
it via the builder (either by the filename or the bundle instance itself). You can also create localized
error messages by providing locale specific variations of bundles, such as
ValidationMessages_en_US.properties
.
When validating, you can provide the locale to use to lookup messages. By default,
Locale#getDefault()
will be used when looking up messages in the bundle.
To register bundles with the validator, add the filename to the validator builder, or directly add the ResourceBundle instance to the builder.
@Valid
public class Car {
// will search configured bundles for bundle.key
@NotNull(message = "{bundle.key}")
private String manufacturer;
}
Message Interpolation
If a constraint is violated, its descriptor will be interpolated by the currently configured
MessageInterpolator
. The interpolated error message can then be retrieved from the resulting
constraint violation by calling ConstraintViolation#getMessage()
. Below is the signature for
MessageInterpolator
.
public interface MessageInterpolator {
/**
* Interpolate the given message with the annotation attributes
*
* @param template The template loaded from annotation/resourceBundle
* @param attributes The Constraint annotation's attributes
* @return The interpolated validation error message
*/
String interpolate(String template, Map<String, Object> attributes);
}
Default Message Interpolation
If no MessageInterpolator
is provided, the default interpolator reads the
string for brackets, then replaces them with the corresponding annotation attribute. See the below example.
@Size(
min = 2,
max = 14,
message = "Value must be between {min} and {max} characters long"
)
private Collection<String> values;
by default, the error message will be interpolated to: Value must be between 2 and 14 characters long.
Grouping Constraints
All validation methods on Validator
take a var-arg argument groups
. Groups allow you to
restrict the set of constraints applied during validation. One use case for validation groups are UI wizards where
in each step only a specified subset of constraints should get validated. The groups targeted are passed as
var-arg
parameters to the appropriate validate method.
Let's have a look at an example. Below we have a set of classes to be validated
class Car {
@NotNull //Group-less constraints will automatically be assigned the avaje Default group.
private String manufacturer;
@NotNull
@Size(min = 2, max = 14)
private String licensePlate;
@Min(2)
private int seatCount;
//Constraints with groups execute only if there is a group matching in the validation request.
@AssertTrue(
message = "The car has to pass the vehicle inspection first",
groups = CarChecks.class
)
private boolean passedVehicleInspection;
// all-args contructor, getters ...
}
// create a car and check that everything is ok with it.
Car car = new Car("Morris", "DD-AB-123", 2, false);
validator.validate(car); // because inspection is not in default group no exception
try {
// Check the inspection (it won't pass)
validator.validate(car, CarChecks.class );
} catch (ConstraintViolationException ex) {
var constraintViolations = ex.violations();
assertEquals(1, constraintViolations.size());
assertEquals(
"The car has to pass the vehicle inspection first",
constraintViolations.iterator().next().getMessage()
);
}
The first validate()
call is done using no explicit group. There are no
validation errors even though the property passedVehicleInspection
is false, as the constraint
defined
on the property does not belong to the default group.
The next validation using the CarChecks
group fails until the car passes the vehicle inspection.
Custom Constraints
Avaje Validation defines a whole set of standard constraint annotations such as @NotNull
,
@Size
etc.
In cases where these built-in constraints are not sufficient, you can easily create custom constraints tailored to
your specific validation requirements.
To create a custom constraint, the following two steps are required:
- Create a constraint annotation
- Implement an adapter to validate objects
@Constraint
We use the @Constraint
annotation to mark an annotation class as a composable constraint annotation.
The below example section shows how to write a constraint annotation which ensures that a given string is either
completely upper case or lower case.
@Constraint //for Jakarta/Javax versions, use @Constraint(validatedBy = { })
public @interface CheckCase {
String message() default "{io.avaje.validator.CheckCase}"; //default error message
Class<?>[] groups() default { }; //groups
CaseMode value(); //specify case mode
public enum CaseMode {
UPPER,
LOWER;
}
}
With our @CheckCase
annotation in hand, we must now create a specialized
AbstractConstraintAdapter
that can validate a value using the annotation's attributes.
@ConstraintAdapter
We use the ConstraintAdapter to mark a type as a Constraint Adapter that targets a constraint annotation.
@ConstraintAdapter(CheckCase.class)
public final class CheckCaseAdapter extends AbstractConstraintAdapter<String> {
private final CaseMode caseMode;
public CheckCaseAdapter(AdapterCreateRequest request) {
super(request);
final var attributes = request.attributes();
caseMode = (CaseMode) attributes.get("caseMode");
}
@Override
public boolean isValid(String object) {
if (object == null) {
return true;
}
if (caseMode == CaseMode.UPPER) {
return object.equals(object.toUpperCase());
} else {
return object.equals(object.toLowerCase());
}
}
}
The AbstractConstraintAdapter
class defines one type parameter which describes the type of elements
which the validator
can handle (String). In case a constraint supports several data types, set the type as Object
and use
JDK 17+ pattern matching to validate each allowed type.
The implementation of the validator is straightforward. The constructor gives you access to the attribute values of the constraint being validated and allows you to store them in a field as shown in the example.
The isValid()
method contains the actual validation logic. For @CheckCase
this is the
check whether a given string
is either completely lower case or upper case, depending on the case mode retrieved by the constructor. Note that
the Jakarta Bean Validation specification recommends to consider null values as being valid. If null is not a
valid
value for an element, it should be annotated with @NotNull
explicitly.
@CrossParamContraint
We use the @CrossParamContraint
annotation to mark an annotation class as a composable constraint
annotation for cross-parameter validations.
These apply to the array of parameters of a method and can be used to express validation logic which depend on
several parameter values.
In the following example, we create a cross param contraint that validates whether a triangle abides by Pythagoras' theorum.
@CrossParamConstraint
@interface Pythagorean {
// message and group members...
}
@Component
public class Geometry {
@Pythagorean
@ValidMethod
String triangle(Integer sideA, Integer sideB, Integer hypotenuse) {
return regular;
}
}
Now we create the corresponding adapter for @Pythagorean
. Cross-param adapters must accept an
Object[]
representing a method's parameters.
@ConstraintAdapter(Pythagorean.class)
public final class PythagoreanAdapter extends AbstractConstraintAdapter<Object[]> {
public PythagoreanAdapter(AdapterCreateRequest request) {
super(request);
}
@Override
public boolean isValid(Object[] params) {
var a = (Integer) params[0];
var b = (Integer) params[1];
var c = (Integer) params[2];
return a * a + b * b == c * c;
}
}
Composing Constraints
You can create high level constraints that composed of several basic constraints @NotNull
,
@Size
etc.
To create a composed constraint, simply annotate the constraint declaration with @Constraint
and its
comprising constraints. The annotation processor will generate an adapter for the composable constraint that will
execute the constraints in sequence.
@NotNull
@Constraint
@Size(min = 2, max = 14)
@CheckCase(CaseMode.UPPER)
public @interface ValidLicensePlate {
String message() default "{io.avaje.ValidLicensePlate.message}";
Class<?>[] groups() default { };
}
Using the new composed constraint at the licensePlate field is fully equivalent to the previous version in the groups example, where the three constraints were declared directly at the field itself:
public class Car {
@ValidLicensePlate
private String licensePlate;
//...
}