Advanced Jakarta 3.0 Validation: Pretty Little Magic U’ll Fall in Love With

Kateryna Hrytsaienko
Google for Developers Europe
9 min readOct 9, 2023

All the cool tricks I use to bring request validation to the next level

Hi there, today we will talk about request validation with Jakarta 3.0 and all the excellent magic tricks you could benefit from. Of course, we all know about @NotNull and @NotEmpty. However, Jakarta’s capabilities are far beyond that. This article aims to check out my most beloved of them with small true-life samples.

Why It’s Cool to Have Validation Separated From Service Logic?

In a few words, it will simplify your life and secure your service layer from leaking validation logic. Also, having a single source of truth for all validation errors that may and will occur is always nice. And the cherry on top — boxing validation exceptions into the Jakarta types allows you to handle all these exceptions centrally with @ControllerAdvice.

Three reasons packed up — pretty lovely things for the developer’s heart.

Fabulous Set of Annotations for Constraint Validation

Let’s start with the built-in annotations you may use out of the box. There are plenty of them for validation of:

  • data-time fields (@Future, @Past, @FutureOrPresent, @PastOrPresent);
  • strings (@Email, @Pattern, @Size, @NotEmpty);
  • iterative objects (@Size, @NotBlank)
  • numeric values (@Digits, @Min and @Max, @Negative, @Positive)
  • boolean values (@AssertTrue, @AssertFalse)
  • objects (@Null, @NotNull, @Valid)

And most of them you are probably already familiar with. However, the genuine fun begins with these annotations’ composition and advanced targeting.

First, you need to know that each build-in annotation (aka Constraint) has a list of targets to which it could be applied. In Jakarta EE, these targets are defined as ElementTypes. Available variants of ElementType are:

  • FIELD for constrained attributes
  • CONSTRUCTOR for constrained constructor return values
  • METHOD for constrained getters and constrained method return values
  • PARAMETER for constrained method and constructor parameters
  • TYPE for constrained beans
  • ANNOTATION_TYPE for constraints composing other constraints
  • TYPE_USE for container element constraints
  • METHOD
  • CONSTRUCTOR
  • ANNOTATION_TYPE for cross-parameter constraints composing other cross-parameter constraints

So what? You may ask, but it’s where the magic begins — all build-in Jakarta constraints could be applied not only to field or method parameters but to the method or constructor itself

Let’s check out how we may use this knowledge by example. Imagine we have such a use case:

We receive the request containing a set of available color themes for an e-shop and defaultColorTheme, which would be displayed automatically. Business logic requires us to validate it for the condition that the set of available color themes contain default one.

Does it smell like validation leaked in the controller layer?

Actually nope. And it could be solved with @AssertTrue annotation.

@JsonProperty("default")
String defaultTheme;

List<String> available;

// marking this field as ignorant for the serializer and deserializer
@JsonIgnore
@AssertTrue(message = "Available themes must contain default theme")
public boolean isAvailableContainsDefault() {
return available.contains(defaultTheme);
}

In the example above, we target @AssertTrue over the method and use @JsonIgnore annotation to hide the introduced field from serialization.

💡 Please be mindful of naming the validation method and access modifier.
The method should be public and start with the prefix "is".
Otherwise, Jakarta will ignore it.

Similarly, we may validate method output as a @Positive integer, ensure that the result date is in the @Future, or control the number of decimal places in the method result using @Digits.

You may play around with other options — check out a complete list of built-in constraints and their property descriptions in the official documentation.

The next step for our magic tour is more advanced tricks, and let’s get strict with the business.

Write Custom Annotations for Even More Lovely Things to Do

The built-in constraints are awesome and nice to use. However, the business often has more complex requirements than out-of-the-box functionality may cover. For such cases, you may define your own Constraint annotations and Validators.

From this point, we need to dig underhood of the Jakarta EE processes (yay)

Few Words on Jakarta Flow

As we know, Jakarta operates with constraints — Java Beans that are marked with the @Constraintannotation and implement Jakarta Validation API obligatory methods:

// defines Jakarta groups that constraint applies to
Class<?>[] groups() default {};

// defines an array of Payload obj associated with the constraint
// defaults to []
Class<? extends Payload>[] payload() default {};

// default message that would be thrown in ConstraintValidationException
String message() default "Error message";

The Constraint interface defines the method validatedBy() used to inject the corresponding validator and defines the target ElementTypes for annotation to apply.

@Target({ ANNOTATION_TYPE })
@Retention(RUNTIME)
public @interface Constraint {
Class<?extends ConstraintValidator<?, ?>>[] validatedBy();
}

To create your annotation, register it via @Constraint and provide the Validator implementation Jakarta would use to check corresponding targets (methods, fields, parameters, etc.)

Defining Annotation

Let’s see how it works with the example:

Imagine we have usecase when we store a e-shop configuration per country. Requirement suggest us to implement delete operation for these configuration by country code, howver we need to validate if the input code is actually in the list of ISO countries set.

So, we will define @ValidCountryCode annotation that checks the path parameter from the REST request to be one of the ISO country codes.

The first step is to define the annotation interface. We will validate the request parameter so that the target element type is PARAMETER (such a surprise). I will also add the custom message for the validation error; other methods I will leave as default; we don’t need them for now.

@Target({ElementType.PARAMETER})
@Retention(RUNTIME)
@Constraint(validatedBy = CountryValidator.class)
@Documented
public @interface ValidCountryCode {

String COUNTRY_CODE_IS_NOT_VALID = "Country code is not valid";

String message() default COUNTRY_CODE_IS_NOT_VALID;

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

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

}

To the @Constraint constructor, I will pass the custom validator class.

According to Jakarta spec, Validators for Constraints must implement methods of ConstraintValidator<A extends Annotation, T>interface — boolean isValid(). Where A is our future annotation, and T is the type of target to validate. In our case — ValidCountryCode and String. We will add simple logic to check whether the input string belongs to ISO counties.

@Component
public class CountryValidator implements ConstraintValidator<ValidCountryCode, String> {
private static final COUNTRIES = Locale.getISOCountries();
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
return !COUNTRIES.stream().noneMatch(element -> element.equals(value));
}
}

And … that’s it! It is so easy and effective now we have a custom annotation to use:

@RestController
@Validated
@RequestMapping(value = "v1/countries")
public class MarketConfigurationsController {
...
@ResponseStatus(HttpStatus.NO_CONTENT)
@DeleteMapping(value = "/:country/", produces = {MediaType.APPLICATION_JSON_VALUE})
public void deleteConfiguration(@PathVariable("country") @ValidCountryCode String country) {
countryService.deleteCountryByCode(country);
}
...
}

Validation Groups

Jakarta validation groups exist to… group annotation constraints. Applause to Captain Obvious!🤣 But the more interesting question is: “Why do we need to group them?”

The answer is pretty simple: ordering and separation. Group provides a mechanism to split validation into the stages — sequences; so that the order of checks is guaranteed.

Let’s take a close look at an example:

Imagine, the request contains time interval, that consist of the start and end date. For simplicity, the time range would be valid if the startDate is before the endDate and both of them are not null.

So, what would SampleRequest validation look like? For starters, we’d use the beloved @NotNull annotation. Then, we’d combine it with @AsserTrue and write the method to compare the start and end dates:

public class SampleRequest {

@NotNull
@JsonFormat(pattern = "yyyy-MM-dd")
LocalDate startDate;

@NotNull
@JsonFormat(pattern = "yyyy-MM-dd")
LocalDate endDate;

@AssertTrue(message = "Start date is after the end date")
@JsonIgnore
public boolean isEndDateAfterStartDate() {
return !endDate.isBefore(startDate);
}
}

At first sight, everything should work perfectly; however, let’s imagine that startDate is null. In this case, request processing will cause an exception while executing validation, specificallyjakarta.validation.ValidationException:

Caused by: jakarta.validation.ValidationException: HV000090: Unable to access isEndDateAfterStartDate.
at org.hibernate.validator.internal.util.ReflectionHelper.getValue(ReflectionHelper.java:126)
at org.hibernate.validator.internal.properties.javabean.JavaBeanGetter$GetterAccessor.getValueFrom(JavaBeanGetter.java:146)
at org.hibernate.validator.internal.metadata.location.AbstractPropertyConstraintLocation.getValue(AbstractPropertyConstraintLocation.java:62)
at org.hibernate.validator.internal.engine.valuecontext.ValueContext.getValue(ValueContext.java:186)
at org.hibernate.validator.internal.engine.ValidatorImpl.validateMetaConstraint(ValidatorImpl.java:552)
at org.hibernate.validator.internal.engine.ValidatorImpl.validateConstraintsForSingleDefaultGroupElement(ValidatorImpl.java:518)
at org.hibernate.validator.internal.engine.ValidatorImpl.validateConstraintsForDefaultGroup(ValidatorImpl.java:488)
at org.hibernate.validator.internal.engine.ValidatorImpl.validateConstraintsForCurrentGroup(ValidatorImpl.java:450)
at org.hibernate.validator.internal.engine.ValidatorImpl.validateInContext(ValidatorImpl.java:400)
at org.hibernate.validator.internal.engine.ValidatorImpl.validateCascadedAnnotatedObjectForCurrentGroup(ValidatorImpl.java:629)
at org.hibernate.validator.internal.engine.ValidatorImpl.validateCascadedConstraints(ValidatorImpl.java:590)

How come?

Jakarta executes all checks randomly and calls @AssertTrue operation isEndDateAfterStartDate() before @NotNull. Results in a null pointer exception raising at !endDate.isBefore(startDate); call.

So that’s when the @GroupSequence comes into play.

We will split the SampleRequest validation into two stages: firstly, do all the “simple” checks, and then proceed with extended validation for the dates.

In Jakarta, groups are introduced as flag interfaces. The order of validation based on group membership is represented with the @GroupSequence annotation. We would pass the list of groups to it as a parameter so the Validator will execute them in the corresponding order.

// introduce the flag interface for group marking
public interface DateExtendedValidation {}

@GroupSequence({SampleRequest.class, DateExtendedValidation.class})
public class SampleRequest {
//...
}

Out of the box, Jakarta has a default group, where all the checks belong from the start. For simplicity, it has the same name as the validated class; in our case, we will pass: {SampleRequest.class, DateExtendedValidation.class} list

Then, to assign the group to the constraint, we need to pass it to the groups’parameter:

public class SampleRequest {

//...
@AssertTrue(message = "Start date is after the end date", groups = DateExtendedValidation.class)
@JsonIgnore
public boolean isEndDateAfterStartDate() {
return !endDate.isBefore(startDate);
}
}

Vualla! Everything works from now on😏

Jakarta Exception Handling with @ControllerAdvice

As was mentioned above, one of Jakarta’s Validation benefits is the exception boxing. Let’s check out how it works by example.

We will use a few more concepts besides Jakarta’s Exception composition. The @ControllerAdvice & @ExceptionHandler and ProblemDetail. Both are general best practices and golden standards, so if you are not familiar with them, I recommend you check out these articles first:

Handling of Validation Exceptions

So, let’s imagine that any rule from the previous step was violated; what’s gonna happen?

For such cases, Jakarta has ConstraintViolationException that contains the set of ConstraintViolation — object storing such details as error cause (default or custom message) and path to invalidated attribute. For reference, please see this paragraph. Seems clear, right? Too bad, but that’s exactly where things got tricky.

The thing is that Jakarta itself doesn’t raise exceptions but rather provides them for the frameworks or applications to use. As stated in the official documentation:

Frameworks and applications are encouraged to use ConstraintViolationException as opposed to a custom exception to increase consistency of the Java platform

Let’s read further and…

Jakarta Bean Validation never raises this exception itself.

But what does it mean for us? No panic; there is no need to implement your handlers to throw an exception or sth. Spring integration doesn’t use the plain Jakarta for the starters.

Spring Data JPA does use Jakarta Persistence JPA. This exact integration will throw an exception for us. That means all we need to do is to add an @ExceptionHandler for ConstraintViolationException in @ControllerAdvice:

@RestControllerAdvice
public class ExceptionTranslator extends ResponseEntityExceptionHandler {

@Builder
private record InvalidatedParams (String cause, String attribute) {}

@ExceptionHandler(ConstraintViolationException.class)
ProblemDetail handleConstraintViolationException(ConstraintViolationException e) {
Set<ConstraintViolation<?>> errors = e.getConstraintViolations();
List<InvalidatedParams> validationResponse = errors.stream()
.map(err -> InvalidatedParams.builder()
.cause(err.getMessage())
.attribute(err.getPropertyPath().toString())
.build()
).toList();

ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(BAD_REQUEST, "Request validation failed");
problemDetail.setTitle("Validation Failed");
problemDetail.setProperty("invalidParams", validationResponse);
return problemDetail;
}
}

Here is the validation failed response example in JSON:

{
"title": "Validation Failed",
"status": 400,
"detail": "Request validation failed"
"invalidParams": [
{
"attribute": "endDate",
"cause": "must not be null"
}
]
}

It looks nice from now on; we get far already. However, there are still a few more steps till the perfect and safe validation flow.

Do you recall that we discussed a few paragraphs above that the Jakarta Persistence wrapper throws all Jakarta exceptions in Sring Data JPA for us? However, the tricky side effect is hidden in this magic.

Whenever we use Jakarta annotations directly to methods params, the “exception boxing” happens, and ConstraintViolationException gets masked with MethodArgumentNotValidException.

Do we need to handle this scenario, then? Well, yes and no. The trick is that MethodArgumentNotValidException is already handled by ResponseEntityExceptionHandler out-of-the-box. However, we may customize this behavior by overriding the handleMethodArgumentNotValid() method:

@RestControllerAdvice
public class ExceptionTranslator extends ResponseEntityExceptionHandler {
// ...
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
List<FieldError> errors = ex.getBindingResult().getFieldErrors();

List<InvalidatedParams> validationResponse = errors.stream()
.map(err -> InvalidatedParams.builder()
.cause(err.getDefaultMessage())
.attribute(err.getField())
.build()
).toList();

ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(BAD_REQUEST, "Request validation failed");
problemDetail.setTitle("Validation Failed");
problemDetail.setProperty("invalidParams", validationResponse);
return ResponseEntity.status(BAD_REQUEST).body(problemDetail);
}
}

Summary

That’s all for today.

Through this article, we get ourselves familiar with Jakarta validation tricks and gain a high-level understanding of how Validators work. Also, now you know how to boost default annotation usage and create your own. In addition, we checked out how to handle Jakarta Validation exceptions.

I hope you enjoyed all these small Jakarta magic tricks as much as I did, and it will help you improve your request validation and save you an evening or two of research. Thanks for reading, and.. see you soon.

Cheers!💫

--

--

Kateryna Hrytsaienko
Google for Developers Europe

Backend developer, Cloud Enthusiast, passionate about CI\CD automation and AI, @GoogleStudentClubs Lead & Mentor at Kyiv Polytechnic Institute #WTMAmbassador