Java Top

Get started with Spring 5 and Spring Boot 2, through the Learn Spring course:

> CHECK OUT THE COURSE

1. Overview

In this tutorial, we'll discuss Constraint Composition for Bean Validation.

Grouping multiple constraints under a single, custom annotation can reduce code duplication and improve readability. We'll see how to create composed constraints and how to customize them according to our needs.

For the code examples, we'll have the same dependencies as in Java Bean Validation Basics.

2. Understanding the Problem

Firstly, let's get familiar with the data model. We'll use the Account class for the majority of the examples in this article:

public class Account {

    @NotNull
    @Pattern(regexp = ".*\\d.*", message = "must contain at least one numeric character")
    @Length(min = 6, max = 32, message = "must have between 6 and 32 characters")
    private String username;
	
    @NotNull
    @Pattern(regexp = ".*\\d.*", message = "must contain at least one numeric character")
    @Length(min = 6, max = 32, message = "must have between 6 and 32 characters")
    private String nickname;
	
    @NotNull
    @Pattern(regexp = ".*\\d.*", message = "must contain at least one numeric character")
    @Length(min = 6, max = 32, message = "must have between 6 and 32 characters")
    private String password;

    // getters and setters
}

We can notice the group of @NotNull, @Pattern, and @Length constraints being repeated for each of the three fields.

Furthermore, if one of these fields is present in multiple classes from different layers, the constraints should match – leading to even more code duplication.

For example, we can imagine having the username field in a DTO object and the @Entity model.

3. Creating a Composed Constraint

We can avoid the code duplication by grouping the three constraints under a custom annotation with a suitable name:

@NotNull
@Pattern(regexp = ".*\\d.*", message = "must contain at least one numeric character")
@Length(min = 6, max = 32, message = "must have between 6 and 32 characters")
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {})
public @interface ValidAlphanumeric {

    String message() default "field should have a valid length and contain numeric character(s).";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

Consequently, we can now use @ValidAlphanumeric to validate Account fields:

public class Account {

    @ValidAlphanumeric
    private String username;

    @ValidAlphanumeric
    private String password;

    @ValidAlphanumeric
    private String nickname;

    // getters and setters
}

As a result, we can test the @ValidAlphanumeric annotation and expect as many violations as violated constraints.

For instance, if we set the username to “john”, we should expect two violations because it's both too short and doesn't contain a numeric character:

@Test
public void whenUsernameIsInvalid_validationShouldReturnTwoViolations() {
    Account account = new Account();
    account.setPassword("valid_password123");
    account.setNickname("valid_nickname123");
    account.setUsername("john");

    Set<ConstraintViolation<Account>> violations = validator.validate(account);

    assertThat(violations).hasSize(2);
}

4. Using @ReportAsSingleViolation

On the other hand, we may want the validation to return a single ConstraintViolation for the whole group.

To achieve this, we have to annotate our composed constraint with @ReportAsSingleViolation:

@NotNull
@Pattern(regexp = ".*\\d.*", message = "must contain at least one numeric character")
@Length(min = 6, max = 32, message = "must have between 6 and 32 characters")
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {})
@ReportAsSingleViolation
public @interface ValidAlphanumericWithSingleViolation {

    String message() default "field should have a valid length and contain numeric character(s).";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

After that, we can test our new annotation using the password field and expect a single violation:

@Test
public void whenPasswordIsInvalid_validationShouldReturnSingleViolation() {
    Account account = new Account();
    account.setUsername("valid_username123");
    account.setNickname("valid_nickname123");
    account.setPassword("john");

    Set<ConstraintViolation<Account>> violations = validator.validate(account);

    assertThat(violations).hasSize(1);
}

5. Boolean Constraint Composition

So far, the validations passed only when all the composing constraints were valid. This is happening because the ConstraintComposition value defaults to CompositionType.AND

However, we can change this behavior if we want to check if there is at least one valid constraint.

To achieve this, we need to switch ConstraintComposition to CompositionType.OR:

@Pattern(regexp = ".*\\d.*", message = "must contain at least one numeric character")
@Length(min = 6, max = 32, message = "must have between 6 and 32 characters")
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {})
@ConstraintComposition(CompositionType.OR)
public @interface ValidLengthOrNumericCharacter {

    String message() default "field should have a valid length or contain numeric character(s).";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

For example, given a value that is too short but has at least one numerical character, there should be no violation.

Let's test this new annotation using the nickname field from our model:

@Test
public void whenNicknameIsTooShortButContainsNumericCharacter_validationShouldPass() {
    Account account = new Account();
    account.setUsername("valid_username123");
    account.setPassword("valid_password123");
    account.setNickname("doe1");

    Set<ConstraintViolation<Account>> violations = validator.validate(account);

    assertThat(violations).isEmpty();
}

Similarly, we can use CompositionType.ALL_FALSE if we want to ensure the constraints are failing.

6. Using Composed Constraints for Method Validation

Moreover, we can use composed constraints as method constraints.

In order to validate a method's return value, we simply need to add @SupportedValidationTarget(ValidationTarget.ANNOTATED_ELEMENT) to the composed constraint:

@NotNull
@Pattern(regexp = ".*\\d.*", message = "must contain at least one numeric character")
@Length(min = 6, max = 32, message = "must have between 6 and 32 characters")
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {})
@SupportedValidationTarget(ValidationTarget.ANNOTATED_ELEMENT)
public @interface AlphanumericReturnValue {

    String message() default "method return value should have a valid length and contain numeric character(s).";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

To exemplify this, we'll use the getAnInvalidAlphanumericValue method, which is annotated with our custom constraint:

@Component
@Validated
public class AccountService {

    @AlphanumericReturnValue
    public String getAnInvalidAlphanumericValue() {
        return "john"; 
    }
}

Now, let's call this method and expect a ConstraintViolationException to be thrown:

@Test
public void whenMethodReturnValuesIsInvalid_validationShouldFail() {
    assertThatThrownBy(() -> accountService.getAnInvalidAlphanumericValue())				 
      .isInstanceOf(ConstraintViolationException.class)
      .hasMessageContaining("must contain at least one numeric character")
      .hasMessageContaining("must have between 6 and 32 characters");
}

7. Conclusion

In this article, we've seen how to avoid code duplication using composed constraints.

After that, we learned to customize the composed constraint to use boolean logic for the validation, to return a single constraint violation, and to be applied to method return values.

As always, the source code is available over on GitHub.

Java bottom

Get started with Spring 5 and Spring Boot 2, through the Learn Spring course:

>> CHECK OUT THE COURSE
Generic footer banner
Comments are closed on this article!