9 min read
Constraint Validation in Spring Boot Microservices

In a microservice architecture, services may accept several, if not many, of the same inputs. This pattern can easily lead to code duplication and redundancy between services. In an effort to mitigate these drawbacks and keep service code focused, we can devise a robust solution involving several APIs provided by Spring and Java.

The following tutorial will assume some working knowledge of Java and Spring Boot, but will cater to a range of skill levels. Regardless, it never hurts to see another developer’s code!

Background

Our solution will involve combining the Java and Spring Boot APIs, ConstraintValidator and ResponseEntityExceptionHandler, respectively.

Hibernate Validator, enhanced as part of JSR 380, is a specification of the Java API for standard Bean validation. In the context of Spring Boot applications, you may have used this without thinking twice. Examples include:

  • @NotNull
  • @Min
  • @Max
  • @Pattern
  • @Past
  • @Email
  • @PositiveOrZero

In this tutorial, we will examine how to go beyond these basic validations using the ConstraintValidator interface to define our own set of constraints.

While other forms of exception handling exist within the Spring ecosystem, the ResponseEntityExceptionHandler provides global (and centralized) exception handling within a service. This globalization is key to the efficacy of our custom constraint annotation since it allows us to validate multiple Beans (or fields within them). That said, we will investigate how we can leverage this class to gracefully handle violations to our constraints.


Implementation

Let’s dive in. To avoid bloating the tutorial with boilerplate code, you will only find necessary blocks of code in the sections below. This article is coupled with a working example on GitHub.

Note: I will be making references to this example project throughout the tutorial.

Dependencies

The list is short and sweet:

implementation 'org.springframework.boot:spring-boot-starter-validation'

Creating the Annotations

We’ll start simple. Let’s say we’ve implemented a Fridge and Pantry Service of which allows us to:

  • Manage the Fridge and Pantry repositories
  • Accept POST and/or PUT requests with a JSON payload

We want to validate common fields between request models of both services. Our request model may look something like this:

public class FoodRequestModel {

    private String name;

    @PositiveOrZero(message = "Quantity must be positive")
    @Max(value = 25, message = "Quantity must not exceed 25")
    private int quantity;

    private String category;

    private boolean refrigerated;

}

One of the simplest constraints we can build will involve composing existing constraints, such as @PositiveOrZero and @Max in the example above. This allows us to put an explicit label on common constraints and call it “business logic”. Below, we define @FoodQuantity:

@Documented
@PositiveOrZero(message = "Quantity must be positive")
@Max(value = 25, message = "Quantity must not exceed 25")
@Constraint(validatedBy = {})
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.PARAMETER})
public @interface FoodQuantity {

    String message() default "Invalid quantity";

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

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

}

There’s a lot going on here, so let’s break this down:

  • @Constraint marks an annotation as being a Bean Validation constraint and allows us to specify ConstraintValidator implementations; zero, one, or many implementations are welcome here
  • @Retention is set such that our annotation will be retained at runtime
  • @Target is set such that we can validate different types of inputs to our services
  • message, groups, and payload are required by @Constraint but do not have to be set—these provide specificity beyond what we’ll cover today

By no means is this a simplification. Looking past the verbosity, the annotation opens quite a few doors to make handling complexity a breeze as we’ll see in the next example.


Let’s define a constraint for the category field such that:

  • Category must be passed and cannot be empty
  • Only certain categories are allowed to be passed
  • Categories may differ between the Fridge and Pantry services

To implement this annotation, we will expand upon the premise of our first annotation by adding a custom parameter and providing an implementation of the ConstraintValidator interface. The result looks something like this:

@Documented
@NotNull(message = "Category must be present")
@NotEmpty(message = "Category must not be empty")
@Constraint(validatedBy = FoodCategoryValidator.class)
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.PARAMETER})
public @interface FoodCategory {

    String message() default "Invalid category";

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

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

    String[] allowed() default {"dairy", "grain"};

}

A few more things are happening in this annotation compared to @FoodQuantity. We’ve specified a new parameter, allowed, to restrict what may be passed into category. Notice the default value—this array is only referenced if values are not passed into @FoodCategory. To handle this constraint, we’ve implemented FoodCategoryValidator:

@Slf4j
public class FoodCategoryValidator implements ConstraintValidator<FoodCategory, String> {

    private List<String> allowed;

    @Override
    public void initialize(FoodCategory constraintAnnotation) {
        this.allowed = Arrays.asList(constraintAnnotation.allowed());
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        log.info("isValid: value=[{}]", value);
        if (!allowed.contains(value.toLowerCase())) {
            String err = "Category must be one of the following: " + allowed;
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate(err)
                    .addConstraintViolation();
            return false;
        }
        return true;
    }

}

Let’s breakdown our new validator class:

  • ConstraintValidator is parameterized with the annotation class and the type being validated—a String containing the value of category
  • A global field allowed, set within the overridden initialize method
    • It is within this method that we gain access to the parameters of @FoodCategory for use throughout the validator class
  • isValid is the meat and bones of our constraint validation
    • For invalid scenarios, we disable the default constraint violation, build a proper error message, and return false—this eventually throws an exception we’ll be interested in later

Lastly, to get the most out of our annotation, we’ll propagate the category field into two subclasses pertaining to each service.

After all our hard work, we’ve reached a clean set of request models ready to be bombarded with invalid values:

public class FoodRequestModel {

    private String name;

    @FoodQuantity
    private int quantity;

    private boolean refrigerated;

}
public class FridgeRequestModel extends FoodRequestModel {

    @FoodCategory(allowed = {"dairy", "vegetables", "beer"})
    private String category;

}
public class PantryRequestModel extends FoodRequestModel {

    @FoodCategory(allowed = {"grains", "canned", "snacks"})
    private String category;

}

Handling Validation Errors

Until now, we’ve only defined constraints we (and our consumers) must follow. Let’s open up an endpoint to allow food to be added to the fridge and test our constraints:

@Slf4j
@Validated
@RestController
@RequestMapping(path = "/api/v1/fridge")
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class FridgeController {

    private final FridgeService fridgeService;

    @PostMapping("/food")
    public ResponseEntity<FoodResponseModel> addFoodToFridgeV1(
            @Valid @RequestBody FridgeRequestModel request) {

        log.info("addFoodToFridgeV1: request=[{}]", request);
        FoodResponseModel response = fridgeService.addFoodToFridge(request);
        return ResponseEntity.ok(response);
    }

}

There are a few critical aspects to note for our annotations to work properly:

  • @Validated must be used either at the class or method level to indicate where validation needs to take place
  • @Valid is used to mark a property for validation cascading, which triggers our constraints

Let’s send a payload:

// POST {{baseUrl}}/api/v1/fridge/food
{
    "name": "milk",
    "category": "dairy",
    "quantity": 2,
    "refrigerated": true
}

Success! But let’s see what happens when we send another payload we know will result in error:

// POST {{baseUrl}}/api/v1/fridge/food
{
    "name": "pinto beans",
    "category": "legumes",
    "quantity": -3,
    "refrigerated": true
}

Note we are violating multiple constraints for this request model. You should see a verbose response containing the details of the errors encountered and the exceptions thrown. This verbosity isn’t ideal for us (or our consumers) to deal with, so let’s filter out important details with a simple override of the ResponseEntityExceptionHandler.

A further look into the error response provided by Spring, you may notice the exception thrown: MethodArgumentNotValidException. This is the exception we will be interested in for handling constraint violations within the exception handler.

First, we need a model to capture relevant information. We’re able to distill the following from the original Spring response:

public class ErrorResponseModel {

    private final String errorMessage;
    private final LocalDateTime timestamp;
    private final String path;

}

Next, we’ll construct both the global exception handler and a method to handle our constraints:

@Slf4j
@RestControllerAdvice
public class ErrorController extends ResponseEntityExceptionHandler {

    @Override
    protected final ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex,
            HttpHeaders headers,
            HttpStatus status,
            WebRequest request) {

        List<String> errorMessages = ex.getBindingResult().getAllErrors().stream()
                .map(DefaultMessageSourceResolvable::getDefaultMessage)
                .collect(Collectors.toList());
        log.error("handleMethodArgumentNotValid: errors=[{}]", errorMessages);
        return new ResponseEntity<>(
                ErrorResponseModel.builder()
                        .errorMessage(errorMessages.get(0))
                        .timestamp(LocalDateTime.now())
                        .path(request.getDescription(false))
                        .build(),
                HttpStatus.BAD_REQUEST
        );
    }

}

Let’s note a few things here:

  • Explore what might differ for violating constraints of @PathVariable or @RequestParam
  • @RestControllerAdvice is just that—a specialized component for classes that declare @ExceptionHandler methods shared across multiple controller classes
  • We override the ResponseEntityExceptionHandler’s handleMethodArgumentNotValid method so we may
    • Log important information
    • Build a small, focused error response
    • Return an HTTP status code of our choice, based on the constraint violated

Upon sending a payload that violates the constraints we’ve defined, you should see a succinct response indicating where we went wrong:

{
    "errorMessage": "Quantity must be positive",
    "timestamp": "2020-11-20T19:18:31.1899697",
    "path": "uri=/api/v1/pantry/food"
}

Challenge

This tutorial provides some basic forms of constraint validation within a Spring-/Java-based REST service. If you are looking to take things a bit further, here are a few places you can start:

  • Implement nested constraints within a complex request model
  • Increase the flexibility of the HTTP status code returned to the consumer
  • Expand the sample project to handle the nuance of a composite service—a Picnic Service, for instance
  • Explore the @Constraint API further—what might payload and groups be used for?

Closing

This concludes the tutorial on implementing custom constraint validators using annotations! Don’t be afraid to let me know if I missed anything. I certainly welcome (and appreciate) criticism, questions, and the like.

For further reference, here is the GitHub repository with the full working code and examples presented in this article.

  • #spring
  • #spring-boot
  • #java
  • #microservices
  • #programming