The Tools of Validation: Bean Validation, Custom Constraints, and Testing

In the first post of this series, we explored 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.

Built-in Constraints #

Spring Boot includes a rich set of built-in constraints:

Annotation Purpose Example
@NotNull Field must not be null @NotNull Integer age
@NotBlank String must not be null, empty, or whitespace @NotBlank String name
@NotEmpty Collection/Map/String must not be empty @NotEmpty List<String> tags
@Size String/Collection/Map size constraints @Size(min = 3, max = 100) String name
@Min / @Max Numeric range constraints @Min(1) @Max(100) Integer age
@DecimalMin / @DecimalMax Decimal range constraints @DecimalMin("0.01") BigDecimal price
@Positive / @Negative Positive/negative number @Positive BigDecimal amount
@Pattern Regex pattern matching @Pattern(regexp = "^[A-Z]{2}[0-9]{6}$") String code
@Email Email format validation @Email String email
@Past / @Future Date in past/future @Past LocalDate birthDate
@PastOrPresent / @FutureOrPresent Date in past/present or future/present @PastOrPresent LocalDate eventDate

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 primarily 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:

Why @NotBlank is Usually Enough for Strings #

For String inputs, @NotBlank is typically sufficient because it already rejects:

So using both @NotNull and @NotBlank on the same String field is usually redundant:

// Redundant
@NotNull
@NotBlank
@Size(max = 100)
String name;

// Preferred
@NotBlank
@Size(max = 100)
String name;

However, for non-String types, @NotNull is still necessary:

// @NotBlank doesn't work on Integer
@NotNull
@Min(1)
Integer age;

Custom Constraints: Beyond the Built-ins #

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

Custom Constraint Example: Swedish Personal Number #

Let’s create a custom constraint for validating Swedish personal identity numbers (personnummer):

@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = SwedishPersonalNumberValidator.class)
public @interface ValidSwedishPersonalNumber {
    String message() default "Invalid Swedish personal number";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
public class SwedishPersonalNumberValidator implements ConstraintValidator<ValidSwedishPersonalNumber, String> {
    
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null || value.isBlank()) {
            return true;  // Let @NotBlank handle null/empty
        }
        
        // Swedish personnummer is 12 digits: YYYYMMDDXXXX
        if (normalized.length() != 12) {
            return false;
        }
        
        // Validate date part (positions 4-7: MMDD)
        int month = Integer.parseInt(normalized.substring(4, 6));
        int day = Integer.parseInt(normalized.substring(6, 8));
        
        if (month < 1 || month > 12) {
            return false;
        }
        
        if (day < 1 || day > 31) {
            return false;
        }
        
        // Validate checksum (Luhn algorithm applied to first 11 digits)
        return calculateChecksum(normalized) == 0;
    }
    
    private int calculateChecksum(String number) {
        int sum = 0;
        // Process first 11 digits: multiply even-indexed digits by 2
        for (int i = 0; i < 11; i++) {
            int digit = Character.getNumericValue(number.charAt(i));
            if (i % 2 == 0) {
                digit *= 2;
                if (digit > 9) {
                    digit -= 9;
                }
            }
            sum += digit;
        }
        // The 12th digit should equal (10 - (sum % 10)) % 10
        int expectedChecksum = (10 - (sum % 10)) % 10;
        int actualChecksum = Character.getNumericValue(number.charAt(11));
        return expectedChecksum == actualChecksum;
    }
}

Using the Custom Constraint #

public record CreateCustomerRequest(
        @NotBlank String name,
        
        @ValidSwedishPersonalNumber
        String personalNumber,
        
        @Email
        String email
) {}

Cross-Field Validation #

Sometimes validation depends on multiple fields. For example, a queue configuration might require certain fields only when a feature is enabled:

@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = QueueConfigurationValidator.class)
public @interface ValidQueueConfig {
    String message() default "Queue configuration is invalid";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
@ValidQueueConfig
@ConfigurationProperties(prefix = "app")
public record AppConfig(
        boolean queueEnabled,
        String queueManager,
        String queueChannel,
        String queueName,
        String queueHost,
        @Min(1) @Max(65535) Integer queuePort,
        String queueUser,
        String queuePassword,
        @Min(1) int maxBatchSize,
        @Min(0) long sleepMillis,
        @NotBlank String encoding
) {}
public class QueueConfigurationValidator implements ConstraintValidator<ValidQueueConfig, AppConfig> {
    
    @Override
    public boolean isValid(AppConfig config, ConstraintValidatorContext context) {
        if (!config.queueEnabled()) {
            return true;  // Validation skipped when queue is disabled
        }
        
        boolean isValid = true;
        
        if (config.queueManager() == null || config.queueManager().isBlank()) {
            isValid = false;
            context.addConstraintViolation()
                   .atField("queueManager")
                   .withMessage("Queue manager is required when queue is enabled");
        }
        
        if (config.queueChannel() == null || config.queueChannel().isBlank()) {
            isValid = false;
            context.addConstraintViolation()
                   .atField("queueChannel")
                   .withMessage("Queue channel is required when queue is enabled");
        }
        
        return isValid;
    }
}

Testing Validation Logic #

Making validation easy to test is crucial for maintainability. Here are strategies for each layer.

Unit Tests for Pure Validators #

For pure, dependency-free validators, use parameterized tests:

@ParameterizedTest
@ValueSource(strings = {
    "121212121212",  // Valid
    "121212121213",  // Invalid checksum
    "121312121212",  // Invalid date
    "12121212121",   // Wrong length
    "1212121212123"  // Wrong length
})
void testSwedishPersonalNumberValidation(String input) {
    var validator = new SwedishPersonalNumberValidator();
    var context = mock(ConstraintValidatorContext.class);
    
    boolean isValid = validator.isValid(input, context);
    
    if (input.equals("121212121212")) {
        assertTrue(isValid);
    } else {
        assertFalse(isValid);
    }
}

Injected Validators with Mockito #

For validators that depend on external services:

@Service
public class RegistrationEmailValidator implements ConstraintValidator<ValidEmail, String> {
    
    private final EmailBlacklistRepository blacklistRepository;
    
    public RegistrationEmailValidator(EmailBlacklistRepository blacklistRepository) {
        this.blacklistRepository = blacklistRepository;
    }
    
    @Override
    public boolean isValid(String email, ConstraintValidatorContext context) {
        if (email == null || email.isBlank()) {
            return true;
        }
        
        if (!email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")) {
            return false;
        }
        
        return !blacklistRepository.isBlacklisted(email);
    }
}
@ExtendWith(MockitoExtension.class)
class RegistrationEmailValidatorTest {
    
    @Mock
    private EmailBlacklistRepository blacklistRepository;
    
    @InjectMocks
    private RegistrationEmailValidator validator;
    
    @Test
    void testValidEmailNotInBlacklist() {
        when(blacklistRepository.isBlacklisted("[email protected]")).thenReturn(false);
        
        assertTrue(validator.isValid("[email protected]", mock(ConstraintValidatorContext.class)));
    }
    
    @Test
    void testEmailInBlacklist() {
        when(blacklistRepository.isBlacklisted("[email protected]")).thenReturn(true);
        
        assertFalse(validator.isValid("[email protected]", mock(ConstraintValidatorContext.class)));
    }
}

Web Slice Tests for DTO + @Valid #

For testing validation at the API boundary:

@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"));
    }
    
    @Test
    void testCreateCustomerWithInvalidPersonalNumber() throws Exception {
        String invalidJson = """
            {
                "name": "John Doe",
                "email": "[email protected]",
                "personalNumber": "121212121213"
            }
            """;
        
        mockMvc.perform(post("/customers")
                .contentType(MediaType.APPLICATION_JSON)
                .content(invalidJson))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errors[0].field").value("personalNumber"));
    }
}

API Error Codes and Structured Responses #

For client-facing APIs, structured error responses with stable codes are essential.

Standard Error Shape #

{
  "type": "validation_error",
  "errors": [
    {
      "field": "email",
      "code": "ERR_INVALID_EMAIL",
      "message": "must be a well-formed email address"
    },
    {
      "field": "personalNumber",
      "code": "ERR_INVALID_PERSONAL_NUMBER",
      "message": "Invalid Swedish personal number checksum"
    }
  ]
}

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));
    }
    
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ValidationErrorResponse> handleConstraintViolations(
            ConstraintViolationException ex) {
        
        List<FieldError> errors = ex.getConstraintViolations().stream()
            .map(violation -> new FieldError(
                violation.getPropertyPath().toString(),
                violation.getConstraintDescriptor().getName(),
                violation.getMessage()
            ))
            .toList();
        
        return ResponseEntity
            .status(HttpStatus.BAD_REQUEST)
            .body(new ValidationErrorResponse("validation_error", errors));
    }
}

Custom Error Codes #

For custom constraints, you can add a code() attribute:

@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = SwedishPersonalNumberValidator.class)
public @interface ValidSwedishPersonalNumber {
    String message() default "Invalid Swedish personal number";
    String code() default "ERR_INVALID_PERSONAL_NUMBER";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

Then in your exception handler, map the constraint type to the code:

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationErrorResponse> handleValidationErrors(
        MethodArgumentNotValidException ex) {
    
    List<FieldError> errors = ex.getBindingResult().getFieldErrors().stream()
        .map(fieldError -> {
            String code = extractErrorCode(fieldError);
            return new FieldError(
                fieldError.getField(),
                code,
                fieldError.getDefaultMessage()
            );
        })
        .toList();
    
    return ResponseEntity
        .status(HttpStatus.BAD_REQUEST)
        .body(new ValidationErrorResponse("validation_error", errors));
}

private String extractErrorCode(FieldError fieldError) {
    // Extract code from annotation or use default
    return fieldError.getCodes()[0];
}

Package Organization for Validation #

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

com.example.customer
  api
    dto
      CreateCustomerRequest.java
      UpdateCustomerRequest.java
      CustomerResponse.java
    validation
      SwedishPersonalNumberValidator.java
      ValidSwedishPersonalNumber.java
      EmailBlacklistValidator.java
  application
    service
      CustomerService.java
    mapper
      CustomerMapper.java
  domain
    model
      Customer.java
      PersonalNumber.java
    rule
      CustomerEligibilityRule.java

Why This Structure Works #

  1. api.validation - Boundary validation specific to each feature
  2. domain.rule - Business rules specific to each domain
  3. shared.validation - Only for truly cross-cutting reusable validators

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

Special Cases: JAXB-Generated Code #

When dealing with JAXB-generated classes (common in legacy systems), don’t spread their null semantics into business logic.

Best Approach: Isolate Behind Mappers #

@Component
public class ShipmentMapper {
    
    public Shipment toDomain(CimShipmentDto dto) {
        String objectId = trim(dto.getObjectId());
        BigDecimal weight = convertToBigDecimal(dto.getWeight());
        BigDecimal volume = convertToBigDecimal(dto.getVolume());
        
        return new Shipment(objectId, weight, volume);
    }
    
    private String trim(String str) {
        if (str == null) {
            return null;
        }
        return str.trim().isEmpty() ? null : str.trim();
    }
    
    private BigDecimal convertToBigDecimal(String str) {
        if (str == null || str.isBlank()) {
            return BigDecimal.ZERO;
        }
        return new BigDecimal(str.trim());
    }
}

Alternative: Wrapper with Builder Pattern #

For more complex scenarios, use a builder that normalizes nulls:

public class ShipmentBuilder {
    
    private String objectId;
    private BigDecimal weight;
    private BigDecimal volume;
    
    public ShipmentBuilder withObjectId(String objectId) {
        this.objectId = (objectId == null) ? null : objectId.trim();
        return this;
    }
    
    public ShipmentBuilder withWeight(BigDecimal weight) {
        this.weight = (weight == null) ? BigDecimal.ZERO : weight;
        return this;
    }
    
    public Shipment build() {
        if (objectId == null || objectId.isBlank()) {
            throw new IllegalArgumentException("ObjectId is required");
        }
        return new Shipment(objectId, weight, volume);
    }
}

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.