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:
@Validis fromjakarta.validationand works on object graphs@Validatedis from Spring and enables method parameter validation
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?
- Invariant Enforcement: Domain validation ensures that no object in your system can ever exist in an invalid state.
- Business Logic: It captures rules that might change frequently without touching your DTOs or controllers.
- Testability: Pure Java validation is easy to unit test without Spring dependencies.
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:
- Boundary: If validation fails, return HTTP 400 immediately.
- Domain: If validation fails, throw an exception that triggers a transaction rollback.
- 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 #
- Jakarta Bean Validation is the foundation for DTO validation with built-in constraints like
@NotNull,@NotBlank,@Size, 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, and WebMvcSlice tests for API boundaries.
- Package organization should be by feature + layer, not one large generic validation package.
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.