Quite often there is a need to apply validation to some of the bean's properties based on the state of the other properties of the same bean. Bean Validation allows to do this in several ways:
- usage of @AssertTrue on a getter method with validation logic
- usage of @ScriptAssert on a class level (Hibernate Validator specific)
- usage of custom constraint annotation on a class level
To show each of the approaches let us consider two classes representing a library member and a book (LibraryMember and Book).
Library members that have passed the trial period of their subscription can take as many books as they'd like, but also as they are regular members - they should be reading at least one book. Other users that are still on a trial period
can have one book at most. Now that we have the beans and know the validation logic - we can proceed to the examples on how such validation rules can be applied using Bean Validation.
Usage of @AssertTrue on a getter method
When any bean is validated by Bean Validation each of its properties (either fields or getters) is checked for
constraints, and if any are present - they are validated. Based on this we can create a custom method returning a
boolean, with
the getter signature, which will contain validation logic. Having such getter in place, we can apply any boolean
constraint to it, either @AssertTrue or @AssertFalse would
work. As we want to check for the bean correctness it make sense to return true from the
getter, if the bean is valid. Hence we use @AssertTrue.
Using the @AssertFalse is
also perfectly fine if it suites the logic more. The updated library member class will look like:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class LibraryMember { | |
// fields and constructors | |
@AssertTrue(message = "Member has incorrect amount of books taken from library") | |
private boolean isValidMember() { | |
if ( LocalDate.now().isBefore( endOfTrialPeriod ) ) { | |
return books.size() < 2; | |
} | |
else { | |
return !books.isEmpty(); | |
} | |
} | |
// the rest of the code... | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// initialize the member bean | |
LibraryMember member = new LibraryMember( | |
"John Doe", | |
LocalDate.now().plusDays( 10 ), | |
Book.getListOfBooks() // returns a list of 3 books | |
); | |
Set<ConstraintViolation<LibraryMember>> violations = validator.validate( member ); | |
// check there's only one violation | |
assertThat( violations ).hasSize( 1 ); | |
ConstraintViolation<LibraryMember> violation = violations.iterator().next(); | |
// check that it's a violation of AssertTrue and that message is correct | |
assertThat( violation.getConstraintDescriptor().getAnnotation().annotationType() ) | |
.isEqualTo( AssertTrue.class ); | |
assertThat( violation.getMessage() ) | |
.isEqualTo( "Member has incorrect amount of books taken from library" ); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class ExtendedLibraryMember extends LibraryMember { | |
// constructors matching parent and nothing else | |
} |
And the corresponding test will look like:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
LibraryMember member = new ExtendedLibraryMember( | |
"John Doe", | |
LocalDate.now().plusDays( 10 ), | |
Book.getListOfBooks() //returns list of 3 books | |
); | |
Set<ConstraintViolation<LibraryMember>> violations = validator.validate( member ); | |
// check there's only one violation | |
assertThat( violations ).hasSize( 1 ); |
Usage of @ScriptAssert as a class level constraint
The previous approach had some downsides, maybe this one would be better? Hibernate Validator as a reference implementation of Bean Validation provides additional functionality and constraints. One of such constraints is @ScriptAssert, which allows to write simple class level constraints using script expressions written in one of the JSR 223 complaint scripting languages. To apply the same validation rule as in the previous case we simply need to add this constraint on a class level as in the following example:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@ScriptAssert( | |
lang = "groovy", | |
alias = "_", | |
script = "java.time.LocalDate.now().isBefore(_.endOfTrialPeriod) ? _.books.size() < 2 : _.books.size() > 0", | |
message = "Member has incorrect amount of books taken from library") | |
class LibraryMember { | |
// all the class logic and fields... | |
} |
As we can see we don't need to modify the class itself. There is no need for additional getters or anything else. And we can
run the similar test to make sure that everything works:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
LibraryMember member = new LibraryMember( | |
"John Doe", | |
LocalDate.now().plusDays( 10 ), | |
Book.getListOfBooks() | |
); | |
Set<ConstraintViolation<LibraryMember>> violations = validator.validate( member ); | |
// check there's only one violation | |
assertThat( violations ).hasSize( 1 ); | |
ConstraintViolation<LibraryMember> violation = violations.iterator().next(); | |
// check that it's a violation of ScriptAssert and that message is correct | |
assertThat( violation.getConstraintDescriptor().getAnnotation().annotationType() ) | |
.isEqualTo( ScriptAssert.class ); | |
assertThat( violation.getMessage() ) | |
.isEqualTo( "Member has incorrect amount of books taken from library" ); |
We moved the validation logic out of the bean into the script expression, but it also made us add additional
dependencies for the scripting language that was chosen (groovy in our case). And also such code is locked from switching to other Bean Validation implementations as the Hibernate Validator specific constraint was used. It is also worth mentioning that
@ScriptAssert is
a repeatable annotation, hence we can add as many validation rules as we'd like, and that's a good thing.
Usage of custom constraint on a class level
To be able to use a custom constraint approach we need to create an annotation for it first:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@Documented | |
@Constraint(validatedBy = {}) | |
@Target({ TYPE }) | |
@Retention(RUNTIME) | |
public @interface ValidLibraryMember { | |
String message() default "Member has incorrect amount of books taken from library"; | |
Class<?>[] groups() default {}; | |
Class<? extends Payload>[] payload() default {}; | |
} |
And implement a ConstraintValidator<ValidLibraryMember, LibraryMember>
interface as a validator which will contain our validation rule logic.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class ValidLibraryMemberValidator implements ConstraintValidator<ValidLibraryMember, LibraryMember> { | |
@Override | |
public boolean isValid(LibraryMember member, ConstraintValidatorContext context) { | |
// null values are considered as valid. | |
if ( member == null ) { | |
return true; | |
} | |
if ( LocalDate.now().isBefore( member.endOfTrialPeriod ) ) { | |
return member.books.size() < 2; | |
} | |
else { | |
return member.books.size() > 1; | |
} | |
} | |
} |
With the constraint and validator in place we can annotate our class with the new constraint annotation:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@ValidLibraryMember | |
public static class LibraryMember { | |
// fields and other logic | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
LibraryMember member = new LibraryMember( | |
"John Doe", | |
LocalDate.now().plusDays( 10 ), | |
Book.getListOfBooks() | |
); | |
Set<ConstraintViolation<LibraryMember>> violations = validator.validate( member ); | |
// check there's only one violation | |
assertThat( violations ).hasSize( 1 ); | |
ConstraintViolation<LibraryMember> violation = violations.iterator().next(); | |
// check that it's a violation of ScriptAssert and that message is correct | |
assertThat( violation.getConstraintDescriptor().getAnnotation().annotationType() ) | |
.isEqualTo( ValidLibraryMember.class ); | |
assertThat( violation.getMessage() ) | |
.isEqualTo( "Member has incorrect amount of books taken from library" ); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class ValidLibraryMemberValidator | |
implements ConstraintValidator<ValidLibraryMember, OtherLibraryMember> { | |
@Override | |
public boolean isValid(OtherLibraryMember member, ConstraintValidatorContext context) { | |
// null values are considered valid. | |
if ( member == null ) { | |
return true; | |
} | |
String messagePattern = null; | |
// determine which message pattern to use based on `endOfTrialPeriod` | |
if ( LocalDate.now().isBefore( member.endOfTrialPeriod ) ) { | |
if ( member.books.size() > 2 ) { | |
messagePattern = "Member is still on trial period and cannot have more " + | |
"than 1 book. But there are %d books."; | |
} | |
} | |
else { | |
if ( member.books.size() < 1 ) { | |
messagePattern = "Member is a regular library visitor and should have more " + | |
"than 1 book. But there is just %d books."; | |
} | |
} | |
// if message pattern was set - there's a violation that we should create: | |
if ( messagePattern != null ) { | |
context.disableDefaultConstraintViolation(); | |
context.buildConstraintViolationWithTemplate( | |
String.format( | |
messagePattern, | |
member.books.size() | |
) | |
).addConstraintViolation(); | |
return false; | |
} | |
// all is valid if this point is reached | |
return true; | |
} | |
} |
Conclusions
We looked at a few ways on how we can implement validation rules, which require knowledge of the state of multiple properties. Let's point out the advantages and disadvantages of each approach as well as when they should be used.Using getter and @AssertTrue.
Advantages:
- Probably the fastest and easiest of all the others to achieve the result
- Validation logic lives in the bean
- Hard to test separate validation rules, as getters are not publicly available
- Can be used when there's a very small amount of such getters and they are relatively simple
Using class level @ScriptAssert.
Advantages:
Advantages:
- When compared to the previous case - removes the validation logic from the bean to script parameter of the annotation. Which removes unnecessary code from the bean itself.
- Additional dependencies for scripting engine
- A strong dependency on Hibernate Validator. Not a big issue as most likely you are using it anyway, but for those who want to be independent from implementations this approach wouldn't work
- Still hard to test separate validation rules
- Similarly to the previous case, can be used when there's relatively small amount of rules with additional condition that corresponding required dependencies are already added for different reasons.
Using custom class level constraint.
Advantages:
Advantages:
- Gives a lot of flexibility
- Easy to test separate rules, as each of validator classes can be tested on its own
- Moves the validation rule logic out of the bean
- Requires to write more classes
- Can be used any time, as long as adding a few classes is not a problem.
All the code samples can be found on GitHub.
To be continued...
COMMENTS