When building a Spring Boot application, you will need to validate the input of web requests, the input to your services, etc. In this blog, you will learn how to add validation to your Spring Boot application. Enjoy!

1. Introduction

In order to validate input, the Jakarta Bean Validation specification will be used. The Jakarta Bean Validation specification is a Java specification which allows you to validate input, models, etc by means of annotations. One of the implementations of the specification is Hibernate Validator. Using Hibernate Validator does not mean that you will also be using the Hibernate ORM (Object Relational Mapping). It is just another project under the Hibernate flag. With Spring Boot, you can add the spring-boot-starter-validation dependency which uses the Hibernate Validator for validating.

In the remainder of this blog, you will create a basic Spring Boot application and add validation to the Controller and the Service.

Sources used in this blog can be found at GitHub.

2. Prerequisites

Prerequisites for this blog are:

  • Basic Java knowledge, Java 21 is used;
  • Basic Spring Boot knowledge;
  • Basic knowledge of OpenAPI, if you do not have any experience with OpenAPI, it is suggested to read a previous blog.

3. Basic Application

The project you will build in this blog, is a basic Spring Boot project. The domain is a Customer with an id, a firstName and a lastName.

public class Customer {
    private Long customerId;
    private String firstName;
    private String lastName;
    ...
}

By means of a Rest API, a customer can be created and retrieved. In order to keep the API specification and source code in line with each other, you will use the openapi-generator-maven-plugin. First you write the OpenAPI specification and the plugin will generate the source code for you based on the specification. The OpenAPI specification consists out of two endpoints, one for creating a customer (POST) and one for retrieving the customer (GET). The OpenAPI specification contains some constraints:

  • The Customer schema used in the POST request limits the number of characters for firstName and lastName. At least one character needs to be provided and a maximum of 20 characters is allowed.
  • The GET request requires the customerId as an input parameter.
openapi: "3.1.0"
info:
  title: API Customer
  version: "1.0"
servers:
  - url: https://localhost:8080
tags:
  - name: Customer
    description: Customer specific data.
paths:
  /customer:
    post:
      tags:
        - Customer
      summary: Create Customer
      operationId: createCustomer
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Customer'
      responses:
        '200':
          description: OK
          content:
            'application/json':
              schema:
                $ref: '#/components/schemas/CustomerFullData'
  /customer/{customerId}:
    get:
      tags:
        - Customer
      summary: Retrieve Customer
      operationId: getCustomer
      parameters:
        - name: customerId
          in: path
          required: true
          schema:
            type: integer
            format: int64
      responses:
        '200':
          description: OK
          content:
            'application/json':
              schema:
                $ref: '#/components/schemas/CustomerFullData'
        '404':
          description: NOT FOUND
components:
  schemas:
    Customer:
      type: object
      properties:
        firstName:
          type: string
          description: First name of the customer
          minLength: 1
          maxLength: 20
        lastName:
          type: string
          description: Last name of the customer
          minLength: 1
          maxLength: 20
    CustomerFullData:
      allOf:
        - $ref: '#/components/schemas/Customer'
        - type: object
          properties:
            customerId:
              type: integer
              description: The ID of the customer
              format: int64
      description: Full data of the customer.

The generated code generates an interface which the CustomerController implements.

  • createCustomer maps the API model to the domain model and invokes the CustomerService;
  • getCustomer invokes the CustomerService and maps the domain model to the API model.
@RestController
class CustomerController implements CustomerApi {

    private final CustomerService customerService;

    CustomerController(CustomerService customerService) {
        this.customerService = customerService;
    }

    @Override
    public ResponseEntity<CustomerFullData> createCustomer(Customer apiCustomer) {
        com.mydeveloperplanet.myvalidationplanet.domain.Customer customer = new com.mydeveloperplanet.myvalidationplanet.domain.Customer();
        customer.setFirstName(apiCustomer.getFirstName());
        customer.setLastName(apiCustomer.getLastName());

        return ResponseEntity.ok(domainToApi(customerService.createCustomer(customer)));
    }

    @Override
    public ResponseEntity<CustomerFullData> getCustomer(Long customerId) {
        com.mydeveloperplanet.myvalidationplanet.domain.Customer customer = customerService.getCustomer(customerId);
        return ResponseEntity.ok(domainToApi(customer));
    }

    private CustomerFullData domainToApi(com.mydeveloperplanet.myvalidationplanet.domain.Customer customer) {
        CustomerFullData cfd = new CustomerFullData();
        cfd.setCustomerId(customer.getCustomerId());
        cfd.setFirstName(customer.getFirstName());
        cfd.setLastName(customer.getLastName());
        return cfd;
    }

}

The CustomerService puts the customer in a Map, no database or whatsoever is used.

@Service
class CustomerService {

    private final HashMap<Long, Customer> customers = new HashMap<>();
    private Long index = 0L;

    Customer createCustomer(Customer customer) {
        customer.setCustomerId(index);
        customers.put(index, customer);
        index++;
        return customer;
    }

    Customer getCustomer(Long customerId) {
        if (customers.containsKey(customerId)) {
            return customers.get(customerId);
        } else {
            return null;
        }
    }
}

Build the application and run the tests.

$ mvn clean verify

4. Controller Validation

The Controller validation is fairly easy now. Just add the spring-boot-starter-validation dependency to your pom.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Take a closer look at the generated CustomerApi interface which is located in target/generated-sources/openapi/src/main/java/com/mydeveloperplanet/myvalidationplanet/api/.

  • At the class level, the interface is annotated with @Validated. This will tell Spring to validate the parameters of the methods.
  • The createCustomer method signature contains the @Valid annotation for the RequestBody. This will tell Spring that this parameter needs to be validated.
  • The getCustomer method signature contains the required property for the customerId.
@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2024-03-30T09:31:30.793931181+01:00[Europe/Amsterdam]")
@Validated
@Tag(name = "Customer", description = "Customer specific data.")
public interface CustomerApi {
    ...
default ResponseEntity<CustomerFullData> createCustomer(
        @Parameter(name = "Customer", description = "") @Valid @RequestBody(required = false) Customer customer
    ) {
        ...
    }
    ...
    default ResponseEntity<CustomerFullData> getCustomer(
        @Parameter(name = "customerId", description = "", required = true, in = ParameterIn.PATH) @PathVariable("customerId") Long customerId
    ) {
        ...
    }
    ...
}

The cool part is, that based on the OpenAPI specification, the correct annotations are put in place in the generated code. You do not need to do anything special in order to add validation to your Rest API.

Let’s test whether validation is doing its job. Only the Controller is tested, the Service is mocked and you will be using the @WebMvcTest annotation in order to slice the test to its minimum.

  • Test whether a BadRequest is returned when a customer is created using a lastName with too many characters;
  • Test a valid customer;
  • Test whether a BadRequest is returned when a customer is retrieved by means of a customerId which is not an integer;
  • Test retrieving a valid customer.
@WebMvcTest(controllers = CustomerController.class)
class CustomerControllerTest {

    @MockBean
    private CustomerService customerService;

    @Autowired
    private MockMvc mvc;

    @Test
    void whenCreateCustomerIsInvalid_thenReturnBadRequest() throws Exception {
        String body = """
                {
                  "firstName": "John",
                  "lastName": "John who has a very long last name"
                }
                """;

        mvc.perform(post("/customer")
                .contentType("application/json")
                .content(body))
                .andExpect(status().isBadRequest());

    }

    @Test
    void whenCreateCustomerIsValid_thenReturnOk() throws Exception {
        String body = """
                {
                  "firstName": "John",
                  "lastName": "Doe"
                }
                """;
        Customer customer = new Customer();
        customer.setCustomerId(1L);
        customer.setFirstName("John");
        customer.setLastName("Doe");
        when(customerService.createCustomer(any())).thenReturn(customer);

        mvc.perform(post("/customer")
                        .contentType("application/json")
                        .content(body))
                .andExpect(status().isOk())
                .andExpect(jsonPath("firstName", equalTo("John")))
                .andExpect(jsonPath("lastName", equalTo("Doe")))
                .andExpect(jsonPath("customerId", equalTo(1)));

    }

    @Test
    void whenGetCustomerIsInvalid_thenReturnBadRequest() throws Exception {
        mvc.perform(get("/customer/abc"))
                .andExpect(status().isBadRequest());
    }

    @Test
    void whenGetCustomerIsValid_thenReturnOk() throws Exception {
        Customer customer = new Customer();
        customer.setCustomerId(1L);
        customer.setFirstName("John");
        customer.setLastName("Doe");
        when(customerService.getCustomer(any())).thenReturn(customer);

        mvc.perform(get("/customer/1"))
                .andExpect(status().isOk());
    }

}

5. Service Validation

Adding validation to the Service requires a bit more effort, but still it is fairly easy.

Add the validation constraints to the model. A complete list of the constraints can be found in the Hibernate Validator documentation.

The following constraints are added:

  • firstName should not be empty and must be between 1 and 20 characters;
  • lastName should not be empty and must be between 1 and 20 characters.
public class Customer {
    private Long customerId;
    @Size(min = 1, max = 20)
    @NotEmpty
    private String firstName;
    @Size(min = 1, max = 20)
    @NotEmpty
    private String lastName;
    ...
}

In order to enable the validation in the Service, two approaches exist. One approach is to inject a Validator in the Service and explicitly validate a customer. This Validator is provided by Spring Boot. If violations are found, you can create the error message.

@Service
class CustomerService {

    private final HashMap<Long, Customer> customers = new HashMap<>();
    private Long index = 0L;
    private final Validator validator;

    CustomerService(Validator validator) {
        this.validator = validator;
    }

    Customer createCustomer(Customer customer) {
        Set<ConstraintViolation<Customer>> violations = validator.validate(customer);

        if (!violations.isEmpty()) {
            StringBuilder sb = new StringBuilder();
            for (ConstraintViolation<Customer> constraintViolation : violations) {
                sb.append(constraintViolation.getMessage());
            }
            throw new ConstraintViolationException("Error occurred: " + sb, violations);
        }
        customer.setCustomerId(index);
        customers.put(index, customer);
        index++;
        return customer;
    }
    ...
}

In order to test the validation of the Service, you need to add the @SpringBootTest annotation. Disadvantage is that it will be a costly test because it will start a full Spring Boot application. Two tests are added:

  • Test whether a ConstraintViolationException is thrown when a customer is created using a lastName with too many characters;
  • Test a valid customer.
@SpringBootTest
class CustomerServiceTest {

    @Autowired
    private CustomerService customerService;

    @Test
    void whenCreateCustomerIsInvalid_thenThrowsException() {
        Customer customer = new Customer();
        customer.setFirstName("John");
        customer.setLastName("John who has a very long last name");

        assertThrows(ConstraintViolationException.class, () -> {
            customerService.createCustomer(customer);
        });
    }

    @Test
    void whenCreateCustomerIsValid_thenCustomerCreated() {
        Customer customer = new Customer();
        customer.setFirstName("John");
        customer.setLastName("Doe");

        Customer customerCreated = customerService.createCustomer(customer);
        assertNotNull(customerCreated.getCustomerId());

    }
}

The second approach to add validation is the same approach as used for the Controller. You add the @Validated annotation to the class level and the @Valid annotation to the argument you want to validate.

@Service
@Validated
class CustomerValidatedService {

    private final HashMap<Long, Customer> customers = new HashMap<>();
    private Long index = 0L;

    Customer createCustomer(@Valid Customer customer) {
        customer.setCustomerId(index);
        customers.put(index, customer);
        index++;
        return customer;
    }
    ...
}

6. Custom Validators

When the standard validators are not sufficient for your use case, you can create your own validators. Let’s create a custom validator for a Dutch zipcode. A Dutch zipcode consists out of 4 digits followed by two characters.

First, you need to create your own constraint annotation. In this case, you only specify the constraint violation message you want to use.

@Target({ FIELD })
@Retention(RUNTIME)
@Constraint(validatedBy = DutchZipcodeValidator.class)
@Documented
public @interface DutchZipcode {

    String message() default "A Dutch zipcode must contain 4 digits followed by two letters";

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

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

}

The annotation is validated by the class DutchZipcodeValidator. This class implements a ConstraintValidator. The isValid method is used to implement the checks and to return whether the input is valid or not. In this case, the checks are implemented by means of a regular expression.

public class DutchZipcodeValidator implements ConstraintValidator<DutchZipcode, String> {

    @Override
    public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
        Pattern pattern = Pattern.compile("\\b\\d{4}\\s?[a-zA-Z]{2}\\b");
        Matcher matcher = pattern.matcher(s);
        return matcher.matches();
    }
}

In order to use the new constraint, you add a new Address domain entity containing a street and a zipcode. The zipcode is annotated with @DutchZipcode.

The new constraint can be tested by means of a basic unit test.

class ValidateDutchZipcodeTest {

    @Test
    void whenZipcodeIsValid_thenOk() {
        Address address = new Address("street", "2845AA");
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        Validator validator = factory.getValidator();
        Set<ConstraintViolation<Address>> violations = validator.validate(address);
        assertTrue(violations.isEmpty());
    }

    @Test
    void whenZipcodeIsInvalid_thenNotOk() {
        Address address = new Address("street", "2845");
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        Validator validator = factory.getValidator();
        Set<ConstraintViolation<Address>> violations = validator.validate(address);
        assertFalse(violations.isEmpty());
    }

}

7. Conclusion

Adding validation to a Controller is almost for free if you define your OpenAPI specification thoroughly and generate the code by means of the openapi-generator-maven-plugin. With limited effort, you can add validation to your Service as well. The Hibernate Validator which is used by Spring Boot offers quite some constraints to be used and you can create your own custom constraints if you need to.