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:
@Validis fromjakarta.validationand works on object graphs@Validatedis from Spring and enables method parameter validation
Why @NotBlank is Usually Enough for Strings
#
For String inputs, @NotBlank is typically sufficient because it already rejects:
null- Empty strings (
"") - Whitespace-only strings (
" ")
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.
Recommended Structure #
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 #
api.validation- Boundary validation specific to each featuredomain.rule- Business rules specific to each domainshared.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 #
- Jakarta Bean Validation is the foundation for DTO validation with built-in constraints like
@NotNull,@NotBlank,@Size,@Pattern, etc. @Validtriggers cascading validation on nested objects, while@Validatedenables method-level validation.- Custom constraints are essential for domain-specific validation like Swedish personal numbers or cross-field validation.
- Testing validation requires different strategies: parameterized tests for pure validators, Mockito for injected validators, and WebMvcSlice tests for API boundaries.
- API error codes should be structured and stable, with a consistent error shape for client consumption.
- Package organization should be by feature + layer, not one large generic validation package.
- JAXB-generated code should be isolated behind mappers or wrappers to prevent null semantics from spreading into business logic.
What’s Next #
In the next article, we’ll explore practical refactoring patterns for existing codebases. We’ll see how to:
- Identify and fix the “silent repair” antipatterns in
DbHandlerand similar classes - Refactor mappers to remove business logic
- Introduce value objects gradually without breaking existing code
- Add validation layers incrementally to legacy systems
Stay tuned for Part 4: Refactoring Validation: From Scattered Checks to Clean Architecture.