Adding Custom Validation in Lombok Builder

What is Lombok?

Lombok is a Java library that auto generates lots of boiler plate code for Java classes. Lombok works at compile time and manipulates Java byte code to add additional features. Lombok uses annotations to specify what boiler plate code will be generates. Eg. @Getter annotation can be applied to any Java bean to auto generate Getter methods for all the fields in that bean.

In this article we will be looking at, how to add custom validations on fields using Lombok Builder annotation

What is Builder annotation in Lombok?

Lombok can be used to generate Builder class for a Java bean, by annotating the bean class with @Builder annotation. 

In certain cases, we might want to validate the data, in builder class, before creating the bean object. Lombok provides simple validator annotations like @NonNull to enforce nullability checks. But what if, we want to have more custom validations or want to throw custom errors.

Customizing Lombok Builder class.

Let's consider below Employee class that requires custom format for `employeeId` field. Also, present in the class, is custom builder that performs validations in the `build()` method.

Employee.java

@Getter
@Builder
public class Employee {

    @NonNull
    private final String name;

    /**
     * Let's add following validations:
     * 1. Employee Id must start with `P` or `T`
     * 2. Must be of length 5
     */
    private final String employeeId;

    @NonNull
    private final LocalDate joiningDate;

    // ##########################################
    // Below we add the custom code to validate employeeId
    // ##########################################

    /**
     * Override the builder() method to return our custom builder instead of the Lombok generated builder class.
     *
     * @return
     */
    public static EmployeeBuilder builder() {
        return new CustomBuilder();
    }

    /**
     * Customized builder class, extends the Lombok generated builder class and overrides method implementations.
     */
    private static class CustomBuilder extends EmployeeBuilder {

        /**
         * Adding validations as part of build() method.
         *
         * @return
         */
        public Employee build() {

            if (super.employeeId == null || super.employeeId.trim().length() != 5) {
                throw new RuntimeException("Employee Id must be of 5 characters");
            }

            final char firstCharacter = super.employeeId.toCharArray()[0];
            if (firstCharacter != 'P' && firstCharacter != 'T') {
                throw new RuntimeException("Employee Id must begin with character `P` or `T`");
            }

            return super.build();
        }
    }
}

We can test the working of the above Employee class using below unit tests.

EmployeeTest.java

class EmployeeTest {

    /**
     * Create Employee object with valid data.
     */
    @Test
    public void valid_data() {
        Employee employee = Employee.builder()
                .name("James Wittel")
                .joiningDate(LocalDate.now())
                .employeeId("P1234")
                .build();

        Assertions.assertNotNull(employee);
    }

    /**
     * Creating employee object with null employee Id should generate error.
     */
    @Test
    public void null_employee_id() {
        Assertions.assertThrows(
                RuntimeException.class,
                () -> Employee.builder()
                        .name("James Wittel")
                        .joiningDate(LocalDate.now())
                        .employeeId(null)
                        .build());
    }

    /**
     * Creating employee object with invalid length employee Id should generate error.
     */
    @Test
    public void invalid_length_employee_id() {
        Assertions.assertThrows(
                RuntimeException.class,
                () -> Employee.builder()
                        .name("James Wittel")
                        .joiningDate(LocalDate.now())
                        .employeeId("P938838838")
                        .build());
    }

    /**
     * Creating employee object with invalid starting character of employee Id, should generate error.
     */
    @Test
    public void invalid_start_character_employee_id() {
        Assertions.assertThrows(
                RuntimeException.class,
                () -> Employee.builder()
                        .name("James Wittel")
                        .joiningDate(LocalDate.now())
                        .employeeId("A9388")
                        .build());
    }
}

4 comments:

  1. This worked perfectly. Thank you for posting it.

    ReplyDelete
  2. This is the most elegant addition I have found so far. The one problem I see here is that the Lombok generated builder class is still available. I think adding access=PRIVATE will take care of that.

    It is still a shame that this isn't just handled by Lombok, but using a subclass is a good solution.

    ReplyDelete
  3. This is a nice solution. One small improvement: use access=PRIVATE. Otherwise, it will be possible to create the Lombok standard builder using new.

    I am also a little concerned about whether this approach will work with the @Jacksonized annotation.

    I really wish to that Lombok had support for this sort of validation.

    ReplyDelete
  4. I also really liked that approach at first, but be aware that it will not really cover you properly if you happen to use `@Builder(toBuilder = true)`.

    Calling `toBuilder()` on an existing instance will then not be using your CustomBuilder, and would thus bypass your custom validations, in its own call to `build()`.

    I don't think it's possible to properly enforce the validations in that case by a similar "trick", unfortunately.

    ReplyDelete