The tools of validation: Bean validation, custom constraints and testing

In the first post of this series, we covered the cost of scattered validation logic. In the second post, we defined the three layers of validation: Boundary, domain and persistence.

Now in this third post, we’ll dive into the tools that make each layer work: Jakarta Bean Validation, Spring’s validation support, custom constraints, and how to test your validation logic effectively.


Jakarta bean validation: the foundation #

Jakarta Bean Validation (JSR 380) provides a standardized way to validate objects using annotations. It’s the backbone of validation in Spring applications.

Below is a table of the constraints used in this post. The full list can be found here:

Annotation Purpose Example
@NotNull Field must not be null @NotNull Integer age
@NotBlank String must not be null, empty, or whitespace @NotBlank String name
@Size String/Collection/Map size constraints @Size(min = 3, max = 100) String name
@Min / @Max Numeric range constraints @Min(1) @Max(100) Integer age
@Email Email format validation @Email String email
@Pattern Regex pattern matching @Pattern(regexp = "^[A-Z]{2}[0-9]{6}$") String code

The @Valid annotation #

The @Valid annotation triggers cascading validation on nested objects:

public record CreateOrderRequest(
        @NotBlank String customerId,

        @Valid  // Triggers validation on nested object
        List<LineItemRequest> items
) {
}

public record LineItemRequest(
        @NotBlank String productId,
        @Min(1) Integer quantity
) {
}

Without @Valid on the items field, the nested LineItemRequest objects would not be validated.

Method validation with @Validated #

While @Valid is for object graph validation, Spring’s @Validated annotation enables method-level validation:


@RestController
@RequestMapping("/orders")
@Validated  // Enables method validation
public class OrderController {

    @GetMapping("/{objectId}")
    public OrderResponse getByObjectId(
            @PathVariable @NotBlank @Size(min = 3, max = 40) String objectId
    ) {
        // objectId is validated before the method executes
        return orderService.getByObjectId(objectId);
    }
}

Key distinction:

With @Validated used as a class annotation, the Jakarta bean validation annotations become active. Without it, those annotations (@NotBlank, @Size) would be completely ignored.


Custom constraints: When the built-ins aren’t enough #

Sometimes the built-in constraints aren’t enough. This is where custom constraints shine.

Custom constraint example: A Swedish personal number validator #

In Sweden, we have something called a personal identity number. The example below covers how to write both a custom annotation you can use and a validator to actually validate the input.

First, create the custom annotation:


@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = SwedishPersonalNumberValidator.class)
public @interface SwedishPersonalNumber {
    String message() default "Invalid Swedish personal number";

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

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

Then create the validator:

public class SwedishPersonalNumberValidator implements ConstraintValidator<SwedishPersonalNumber, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null || value.isBlank()) {
            // Let @NotBlank handle required fields
            return true;
        }

        String normalized = value
                .replace("-", "")
                .replace("+", "");

        if (normalized.length() == 12) {
            normalized = normalized.substring(2);
        }

        if (!normalized.matches("\\d{10}")) {
            return false;
        }

        if (!isValidDate(normalized.substring(0, 6))) {
            return false;
        }

        return isValidChecksum(normalized.substring(0, 9), normalized.substring(9));
    }

    private static boolean isValidDate(String date) {
        try {
            LocalDate.parse(date, DateTimeFormatter.ofPattern("yyMMdd"));
            return true;
        } catch (DateTimeParseException _) {
            return false;
        }
    }

    private static boolean isValidChecksum(String pin, String checksum) {
        int temp;
        int sum = 0;

        for (int i = 0; i < pin.length(); i++) {
            temp = Character.getNumericValue(pin.charAt(i));
            temp *= 2 - (i % 2);
            sum += 1 + (temp - 1) % 9;
        }

        return (10 - (sum % 10)) % 10 == Integer.parseInt(checksum);
    }
}

Now you can use it in your DTO:

public record CreateCustomerRequest(
        @NotBlank String name,

        @NotBlank
        @SwedishPersonalNumber
        String personalNumber,

        @Email
        String email
) {
}

Custom constraint example: Configuration validation #

Sometimes you need to validate configuration properties. Here’s how you validate a message queue configuration:


@Validated
@QueueConfig
@ConfigurationProperties(prefix = "app.mq")
public record MqConfig(
        boolean enabled,
        String manager,
        String channel,
        @NotBlank String name,
        @NotBlank String host,
        @Min(1025) @Max(65535) Integer port,
        @NotBlank String user,
        @NotBlank String password,
        @Min(1) @Max(900) Integer batchSize,
        @NotBlank String encoding
) {
}

The custom annotation:


@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = QueueConfigValidator.class)
public @interface QueueConfig {
    String message() default "Queue configuration is invalid";

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

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

And the validator:

public class QueueConfigValidator implements ConstraintValidator<QueueConfig, MqConfig> {

    @Override
    public boolean isValid(MqConfig config, ConstraintValidatorContext context) {
        if (!config.enabled()) {
            return true; // We do not need a valid configuration unless queues are enabled
        }

        boolean isValid = true;

        if (config.manager() == null || config.manager().isEmpty()) {
            isValid = false;
            context
                    .buildConstraintViolationWithTemplate("Manager is required when queue is enabled")
                    .addPropertyNode("manager")
                    .addConstraintViolation();
        }

        if (config.channel() == null || config.channel().isEmpty()) {
            isValid = false;
            context
                    .buildConstraintViolationWithTemplate("Channel is required when queue is enabled")
                    .addPropertyNode("channel")
                    .addConstraintViolation();
        }

        return isValid;
    }
}

If you run the application with a configuration that has app.mq.enabled set to true, all other required values set to something sensible, but leave the fields app.mq.name and app.mq.manager empty, the following output would be produced during startup:

***************************
APPLICATION FAILED TO START
***************************

Description:

Binding to target org.example.java.configurations.MqConfig failed:

    Reason: Queue configuration is invalid

    Property: app.mq.name
    Value: ""
    Reason: must not be blank

    Property: app.mq.manager
    Value: ""
    Reason: Manager is required when queue is enabled

Action:

Update your application's configuration

Domain layer validation: Business invariants #

While Bean Validation is great for checking data formats (boundary validation), the domain layer needs validation of business rules and invariants. This is where you ensure your core business objects stay valid.

In the domain layer, we move away from @NotNull and @Size. Here we resort to good old if statements:

public record Order(
        String id,
        CustomerId customerId,
        List<OrderItem> items
) {
    public Order {
        if (items == null || items.isEmpty()) {
            throw new IllegalArgumentException("An order must contain at least one item");
        }

        var totalQuantity = items.stream()
                .map(OrderItem::quantity)
                .reduce(0, Integer::sum);

        if (totalQuantity <= 0) {
            throw new IllegalArgumentException("Order total quantity must be greater than zero");
        }

        if (customerId == null) {
            throw new IllegalArgumentException("Customer ID is required");
        }
    }
}

Why validate in the domain?


Persistence layer validation: Database constraints #

The persistence layer is the final gatekeeper. Even with robust application-level validation, you must protect your database integrity.

JPA/Hibernate annotations provide a direct mapping to database constraints:


@Entity
@Table(name = "customers")
public class Customer {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @Column(nullable = false)  // DB Constraint
    @NotNull                  // Bean Validation
    private String email;

    @Column(unique = true)     // DB Constraint
    @PastOrPresent             // Bean Validation
    private LocalDate birthDate;

    // Getters...
}

Key Differences Between Layers

Layer Primary Concern Typical Tools Error Handling
Boundary Request/Response Format @Valid, @NotBlank HTTP 400 Bad Request
Domain Business Invariants Java Logic, Records Application Exceptions
Persistence Data Integrity JPA, DB Constraints Transaction Rollback

Why duplicate validation? Validation is redundant by design. If your application validation fails, the user gets a clear error immediately. If your database validation fails (e.g., concurrent updates bypassing application logic), the transaction rolls back safely.


Testing validation logic #

Being able to test validators is crucial for long-term maintainability. Here’s how to test each layer:

Unit tests for pure validators #


@ParameterizedTest
@ValueSource(strings = {
        "189005129813",
        "189005159802",
        "189005199808"
})
void testSwedishPersonalNumberValidation(String input) {
    var validator = new SwedishPersonalNumberValidator();
    var context = mock(ConstraintValidatorContext.class);

    boolean isValid = validator.isValid(input, context);
    Assertions.assertTrue(isValid);
}

Domain validation tests #

Domain validation tests should be pure unit tests without Spring dependencies:

class OrderTest {

    @Test
    void testOrderWithEmptyItemsThrowsException() {
        Assertions.assertThrows(IllegalArgumentException.class, () -> new Order(
                "1",
                new CustomerId("1"),
                List.of()
        ));
    }

    @Test
    void testValidOrderIsCreated() {
        var order = new Order(
                "1",
                new CustomerId("1"),
                List.of(new OrderItem("1", 1))
        );

        Assertions.assertNotNull(order);
        Assertions.assertEquals(1, order.items().size());
    }
}

API boundary tests #


@WebMvcTest(controllers = CustomerController.class)
class CustomerControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void testCreateCustomerWithInvalidEmail() throws Exception {
        String invalidJson = """
                {
                    "name": "John Doe",
                    "email": "invalid-email",
                    "personalNumber": "121212121212"
                }
                """;

        mockMvc.perform(post("/customers")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(invalidJson))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errors[0].field").value("email"));
    }
}

Validation flow across layers #

Here’s how validation flows through a request:

R ( e J q S u O e N s ) t R E e r t r B ( u o o B r r u e n n a s r d n e a 4 s r V 0 p y a 0 o l n i s d e . ) T ( D ( h R o I r o m n o l a v w l i a s b n r a i E c a x k n c ) t e s p ) t i o n
  1. Boundary: If validation fails, return HTTP 400 immediately.
  2. Domain: If validation fails, throw an exception that triggers a transaction rollback.
  3. Persistence: If validation fails (e.g., unique constraint violation), catch and map to a 409 Conflict.

API error codes and structured responses #

For any API, returning structured responses with stable error codes is essential:

{
  "type": "validation_error",
  "errors": [
    {
      "field": "email",
      "code": "ERR_INVALID_EMAIL",
      "message": "must be a well-formed email address"
    }
  ]
}

Global Exception Handler #


@RestControllerAdvice
public class ValidationExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ValidationErrorResponse> handleValidationErrors(
            MethodArgumentNotValidException ex) {

        List<FieldError> errors = ex.getBindingResult().getFieldErrors().stream()
                .map(fieldError -> new FieldError(
                        fieldError.getField(),
                        fieldError.getCode(),
                        fieldError.getDefaultMessage()
                ))
                .toList();

        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(new ValidationErrorResponse("validation_error", errors));
    }
}

Package Organization for Validation #

Organize validation code by feature + layer, rather than one large generic validation package:

com.example.customer
  api
    dto
      CreateCustomerRequest.java
    validation
      SwedishPersonalNumberValidator.java
      ValidSwedishPersonalNumber.java
  application
    service
      CustomerService.java
  domain
    model
      Customer.java
    rule
      CustomerEligibilityRule.java

This keeps validation logic close to where it’s used while allowing reuse when appropriate.


Key Takeaways #


What’s Next #

In the next article, we’ll explore practical refactoring patterns for existing codebases. We’ll see how to:

Stay tuned for Part 4: Refactoring Validation: From Scattered Checks to Clean Architecture.