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)

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>

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 flag to re-enable.

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-compiler-plugin</artifactId>
  <configuration>
    <compilerArgument>-proc:full</compilerArgument>
  </configuration>
</plugin>

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.

@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:

  1. Add a requires clause for io.avaje.validator
  2. Add a provides clause for io.avaje.validator.validator.GeneratedComponent
Example module-info
import io.avaje.validation.Validator.GeneratedComponent;
import io.avaje.validation.spi.ValidatorCustomizer;

module org.example {

  requires io.avaje.validator;

  //if defining validation customizers add:
  provides ValidatorCustomizer with org.example.validator.MyCustomizer;

  // you must define the fully qualified class name of the generated classes. if you use an import statement, compilation will fail
  provides GeneratedComponent with org.example.validator.GeneratedComponent;

  //if running using Jlink application images with avaje-inject add:
  //requires io.avaje.validator.plugin;

  //if running using Jlink 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 Integrations

When used with Avaje Inject, a default Validator and AOPMethodValidator instance will be provided. The following properties can be added to configure the default Validator instance.

validation.failFast //enable fail fast mode
validation.resourcebundle.names // name of ResourceBundle files to load
validation.locale.default //default local to use (default 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;
  }
}

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:

@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.

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:

  1. Create a constraint annotation
  2. 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;

  //...
}