Spring Boot Custom Validation With Examples

Spring Boot Validation module provides a number of built-in validators for request validation as covered in my previous tutorial . In some cases built-in validations may not be sufficient. In such cases you can easily create custom validations to validate the request.

In this tutorial, I will explain step by step process to create custom validations and apply them to request. The custom validations can be applied at field level and class level .

Steps to Create custom validation

To create a custom validations, the following three steps are required:

• Create a validation annotation
• Implement a validator
• Define a default error message

Creating custom field validation

First we will create a custom validation which can be applied to field.

We will consider following acceptance criteria for employee first name.

First name can contain only letters and spaces only.

Note : This validation can be achieved with @Pattern in built validation but demonstration purpose I am taking this scenario.

The validation annotation

First we need to write validation annotation which can be used to validate that first name contains only letters and spaces.

This constraint will be applied to first name field of employee object.

@Target({ FIELD, METHOD, PARAMETER, ANNOTATION_TYPE, TYPE_USE })
@Retention(RUNTIME)
@Constraint(validatedBy = NameValidator.class)
@Documented
public @interface ValidName {
    String message() default "{validation.validName}";
    Class<?>[] groups() default { };
    Class<? extends Payload>[] payload() default { };
}

An annotation type is defined using the @interface keyword.

The constraint annotation is decorated with a couple of meta annotations

@Target({ FIELD, METHOD, PARAMETER, ANNOTATION_TYPE, TYPE_USE}): Defines the
supported target element types for the constraint. @ValidName cabe used on fields,method return values,constructor paramters and type argument of parameterized types.

The element type ANNOTATION_TYPE allows for the creation of composed constraints (i.e. we can combine multiple constraints to create single constraint)

@Retention(RUNTIME): Specifies, that annotations of this type will be available at runtime by
the means of reflection.

@Constraint(validatedBy = NameValidator.class) – Marks the annotation type as
constraint annotation and specifies the validator to be used to validate elements annotated with

@Documented – use of @ValidName will be contained in the JavaDoc of elements
annotated with it

The Jakarta Bean Validation API standard requires that any validation annotation define:

message – returns the default key for creating error messages in case the constraint is violated

groups – allows the specification of validation groups, to which this constraint belongs. For more information refer my previous tutorial

payload – can be used by clients of the Jakarta Bean Validation API to assign custom payload objects to a constraint.

The custom validator

Once we define the annotation need to create a validator which implements the validation logic, which is able to validate the elements with @ValidName annotation.

public class NameValidator implements ConstraintValidator<ValidName,String> {


    @Override
    public void initialize(ValidName constraintAnnotation) {
        ConstraintValidator.super.initialize(constraintAnnotation);
    }
    @Override
    public boolean isValid(String name, ConstraintValidatorContext context) {
        if(name == null) {
            return true;
        }
        if(name.matches("^[a-zA-Z\\s]*$")) {
            return true;
        }

        return false;
    }
}

The ConstraintValidator interface defines two type parameters which are set in the
implementation

  1. Annotation type to validated (ValidName)
  2. type of element validation is applied (String)

initialize() – The method is useful if you are passing any attributes to the annotation. You can access the attribute values passed to the annotation and store them in fields of the validator.

isValid() – This method contains actual validation logic. Here we check whether name contains only letters and spaces only.

Note : As per the Jakarta Bean Validation specification recommendation null values are considered as being valid.. If null is not a valid value for an element, it should be annotated with @NotNull explicitly.

The error message

Now let’s prepare the error message which should be used in case @ValidName validation fails.

First create ValidationMessages.properties file under resources folder.

Note : By default Spring Boot validation module reads error messages from ValidationMessages.properties file as module internally uses hibernate validator.

Place the following content the file.

validation.validName= First Name should contain only letters and spaces

The key defined here should match with message of the corresponding containt annotation.

public @interface ValidName {
    String message() default "{validation.validName}";
    ...
}

Using the validation

@Entity
@ConditionalNotNull(fields = "salary,email",dependsOn = "hire_date" )
public class Employee implements Serializable {

    ...

    @NotBlank(message = "FirstName should not be blank")
    @Size(min = 3,message = "FirstName should be at least 3 chars")
    @ValidName
    private String first_name;

    @NotBlank(message = "LastName should not be blank")
    @Size(min = 3,message = "LastName should be at least ${min} chars")

    private String last_name;

    @Enumerated(EnumType.STRING)
    private Gender gender;
...
}

Testing the validator

Creating custom class validation

Now let’s create custom validator which can be applied at class level. We should create class level custom validator when validation depends on more than one field.

We are going to use following criteria for validation

When hire date is present in the request, salary and email should not be null.

Creating class level custom validator also follows same 3 step procedure described above

In this case, instead of creating specific validator which checks presence of email and salary when hire date is present in Employee object , we will create a generic validator which can be used for other places with similar scenarios.

I am going name it as ConditionalNotNull validator, and pass two attributes fields and dependson.

fields – you can specify which properties should not be null .

dependsOn – specifies which field presence should be checked.

Let me first show you , how to use ConditionalNotNull validation.

@ConditionalNotNull(fields = "salary,email",dependsOn = "hire_date" )
public class Employee implements Serializable {
}

The validation annotation

@Target({ FIELD, METHOD, PARAMETER, ANNOTATION_TYPE, TYPE_USE })
@Retention(RUNTIME)
@Constraint(validatedBy = ConditionalNotNullValidator.class)
@Documented
@Repeatable(ConditionalNotNull.List.class)
public @interface ConditionalNotNull {
    String message() default "{validation.conditionalNotNull}";
    Class<?>[] groups() default { };
    Class<? extends Payload>[] payload() default { };
    String fields() default "";
    String dependsOn() default "";

    @Target({ FIELD, METHOD, PARAMETER, ANNOTATION_TYPE, TYPE_USE })
    @Retention(RUNTIME)
    @Documented
    @interface List {
        ConditionalNotNull[] value();
    }
}

The annotation is similar to field level validator annotation but contains some extra fields.

fields – variable holds the values passed through fields attribute.

dependsOn – variable holds the values passed through dependsOn attribute

@Repeatable(List.class) – Indicates that the annotation can be repeated several times at the
same place, usually with a different configuration. List is the containing annotation type.

It allows to specify several @ConditionalNotNull annotations on the class level.

public class ConditionalNotNullValidator implements ConstraintValidator<ConditionalNotNull,Object> {

    List<String> fields;
    String dependsOnField;

    @Override
    public void initialize(ConditionalNotNull constraintAnnotation) {
        fields = Arrays.stream(constraintAnnotation.fields().split(",")).collect(Collectors.toList());
        dependsOnField = constraintAnnotation.dependsOn();

    }

    @Override
    public boolean isValid(Object o, ConstraintValidatorContext context) {

        if(o == null) {
            return true;
        }
        Object fieldValue = getFieldValue(dependsOnField,o);
        if(fieldValue == null) {
            return true;
        }
       List<String> errorFields =  fields.stream().filter(f -> getFieldValue(f,o) == null).collect(Collectors.toList());
        if(errorFields.isEmpty()) {
            return true;
        }



        return false;
    }



    public static Object getFieldValue(String fieldName,Object object) {
        try {
            Field field = object.getClass().getDeclaredField(fieldName);
            field.setAccessible(true);
            return  field.get(object);
        }  catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
    }
}

As we are developing the generic validation, we pass generic object as one of parameter to ConstraintValidator (ConstraintValidator<ConditionalNotNull,Object> ) and use the reflection to get the field values

in initialize method we read the passed in properties to the validator and inside isValid method use the reflection to get field values and validate as per logic.

The error message

Now let’s prepare the error message which should be used in case @ConditionalNotNull validation fails.

First create ValidationMessages.properties file under resources folder.

Place the following content in the ValidationMessages.properties file.

validation.conditionalNotNull= {fields} should not be null when {dependsOn}  is not null

fields, dependsOn acts as place holders in above error message which gets interpolated with passes in attributes of validator.

Using the validation

@Entity
@ConditionalNotNull(fields = "salary,email",dependsOn = "hire_date" )
public class Employee implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Null(groups = {OnCreate.class})
    @NotNull(groups = OnUpdate.class)
    private Integer id;

   @PastOrPresent
    private LocalDate hire_date;

    
    @Email 
   private String email;

    @PositiveOrZero
    private BigDecimal salary;
    ....
}

Testing the validation

If you look at the above error message, fields,dependsOn fields from the error message got interpolated.

If you look at the message, even though email is not null, in above request it is shown the message. If you want to customize the message further by only showing null fields you can do passing fields parameter to the context like below

 @Override
    public boolean isValid(Object o, ConstraintValidatorContext context) {

        if(o == null) {
            return true;
        }


        Object fieldValue = getFieldValue(dependsOnField,o);
        if(fieldValue == null) {
            return true;
        }
       List<String> errorFields =  fields.stream().filter(f -> getFieldValue(f,o) == null).collect(Collectors.toList());
        if(errorFields.isEmpty()) {
            return true;
        }

       ((HibernateConstraintValidatorContext)context.unwrap(HibernateConstraintValidatorContext.class)).addMessageParameter("fields",String.join(",",errorFields));


        return false;
    }

If you test now, as only Salary field is null, error message’s fields variable is interpolated with

Overriding the Default Error Message

The validation implementations discussed above relies on default error message generation by just returning true or false from the isValid() method. Using the passed ConstraintValidatorContext object, it is possible to either add additional error
messages or completely disable the default error message generation and solely define custom error messages.

Let’s demonstrate this with an example.

ConditionalNotNull – validation checks that when hire date is not null, email and salary should be not be null..

let’s consider following condition,

When hire_date is null, email and salary should also be null then we can modify the validation logic like below instead of writing another validation.

@Override
    public boolean isValid(Object o, ConstraintValidatorContext context) {

        if(o == null) {
            return true;
        }


        Object fieldValue = getFieldValue(dependsOnField,o);

       List<String> errorFields =  fields.stream().filter(f -> getFieldValue(f,o) == null).collect(Collectors.toList());

        if(fieldValue !=null && errorFields.isEmpty()) {
            return true;
        }

        

        if(fieldValue ==null && errorFields.isEmpty()) {

            context.disableDefaultConstraintViolation();
            context
                    .buildConstraintViolationWithTemplate("{validation.custom.conditionalNull}")
                   .addConstraintViolation();
        }

        return false;
    }

In above code you can see that we are overriding the default error message configured depending on the scenario.

Below you can see the test scenario.

You can download the source code for this blog post from GitHub

Similar Posts