Bean Validation: @Valid, Custom Validators, and Error Messages
Every request that enters your API needs validation. Without it, invalid data propagates through your application and produces confusing errors deep in the stack. This article covers how to validate data at the API boundary using Jakarta Bean Validation.
Setup
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
This includes Hibernate Validator — the reference implementation of Jakarta Bean Validation 3.0.
Built-in Constraints
Annotate fields in your DTO with constraint annotations:
public record CreateOrderRequest(
@NotNull(message = "Customer ID is required")
UUID customerId,
@NotEmpty(message = "Order must contain at least one item")
@Size(max = 50, message = "Order cannot have more than {max} items")
List<@Valid OrderItemRequest> items,
@Valid
ShippingAddressRequest shippingAddress,
@Size(max = 20, message = "Promo code cannot exceed {max} characters")
String promoCode
) {}
public record OrderItemRequest(
@NotNull
UUID productId,
@Positive(message = "Quantity must be positive")
@Max(value = 999, message = "Cannot order more than {value} units of a product")
int quantity,
@NotNull
@Positive
BigDecimal unitPrice
) {}
public record ShippingAddressRequest(
@NotBlank(message = "Address line 1 is required")
@Size(max = 100)
String line1,
@Size(max = 100)
String line2,
@NotBlank
@Size(max = 50)
String city,
@NotBlank
@Pattern(regexp = "[A-Z]{2}", message = "Country must be a 2-letter ISO code")
String country,
@NotBlank
@Pattern(regexp = "\\w{3,10}", message = "Invalid postal code format")
String postalCode
) {}
Common Constraint Annotations
| Annotation | Validates |
|---|---|
@NotNull | Value is not null |
@NotEmpty | String/collection not null and not empty |
@NotBlank | String not null, not empty, not just whitespace |
@Size(min, max) | String length or collection size |
@Min(value) | Number ≥ value |
@Max(value) | Number ≤ value |
@Positive | Number > 0 |
@PositiveOrZero | Number ≥ 0 |
@Negative | Number < 0 |
@Pattern(regexp) | String matches regex |
@Email | Valid email format |
@Past | Date is in the past |
@Future | Date is in the future |
@DecimalMin(value) | Decimal ≥ value (as string) |
@AssertTrue | Boolean is true |
@AssertFalse | Boolean is false |
Triggering Validation with @Valid
Add @Valid to the controller parameter to trigger validation:
@PostMapping("/api/orders")
public ResponseEntity<OrderResponse> createOrder(
@RequestBody @Valid CreateOrderRequest request) {
// If validation fails, MethodArgumentNotValidException is thrown
// before this method body runs
Order order = orderService.create(request);
return ResponseEntity.created(URI.create("/api/orders/" + order.id()))
.body(OrderResponse.from(order));
}
When validation fails, Spring throws MethodArgumentNotValidException. Without a @ControllerAdvice (covered in Article 12), this returns a 400 with an unhelpful response. We’ll handle it properly there.
Validating Nested Objects
Use @Valid on nested fields to cascade validation:
public record CreateOrderRequest(
@NotNull UUID customerId,
@Valid // ← cascades validation into each OrderItemRequest
@NotEmpty
List<OrderItemRequest> items,
@Valid // ← cascades validation into ShippingAddressRequest
ShippingAddressRequest shippingAddress
) {}
Without @Valid on the nested field, constraints on OrderItemRequest and ShippingAddressRequest are ignored.
Custom Constraints
When built-in annotations aren’t enough, write your own.
Example: @ValidCurrency
Validate that a string is a known ISO 4217 currency code:
Step 1 — the annotation:
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = CurrencyValidator.class)
@Documented
public @interface ValidCurrency {
String message() default "Invalid currency code";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
Step 2 — the validator:
public class CurrencyValidator implements ConstraintValidator<ValidCurrency, String> {
private static final Set<String> VALID_CURRENCIES =
Currency.getAvailableCurrencies().stream()
.map(Currency::getCurrencyCode)
.collect(Collectors.toSet());
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) return true; // let @NotNull handle nulls
return VALID_CURRENCIES.contains(value.toUpperCase());
}
}
Step 3 — use it:
public record CreateOrderRequest(
@NotNull UUID customerId,
@NotBlank @ValidCurrency String currency,
// ...
) {}
Example: @FutureDelivery (cross-field validation)
Validate that delivery date is at least 3 days from now:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = FutureDeliveryValidator.class)
public @interface FutureDelivery {
String message() default "Delivery date must be at least 3 days from now";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class FutureDeliveryValidator
implements ConstraintValidator<FutureDelivery, CreateOrderRequest> {
@Override
public boolean isValid(CreateOrderRequest request, ConstraintValidatorContext ctx) {
if (request.requestedDeliveryDate() == null) return true;
LocalDate minDate = LocalDate.now().plusDays(3);
return !request.requestedDeliveryDate().isBefore(minDate);
}
}
Apply it at the class level:
@FutureDelivery // validates the whole object
public record CreateOrderRequest(
@NotNull UUID customerId,
LocalDate requestedDeliveryDate,
// ...
) {}
Injecting Spring Beans into Validators
Sometimes your validator needs to query the database. Hibernate Validator supports Spring injection:
public class ProductExistsValidator implements ConstraintValidator<ProductExists, UUID> {
@Autowired // Spring injects this — works when Spring manages the validator
private ProductRepository productRepository;
@Override
public boolean isValid(UUID productId, ConstraintValidatorContext ctx) {
if (productId == null) return true;
return productRepository.existsById(productId);
}
}
This works automatically in Spring Boot — the LocalValidatorFactoryBean is configured to use Spring’s BeanFactory for validator instantiation.
Validation Groups
Sometimes you need different validation rules for create vs update. Use groups:
// Marker interfaces for groups
public interface OnCreate {}
public interface OnUpdate {}
public class OrderRequest {
@Null(groups = OnCreate.class) // must be null on create
@NotNull(groups = OnUpdate.class) // must be set on update
private UUID id;
@NotBlank(groups = {OnCreate.class, OnUpdate.class})
private String orderNumber;
@NotEmpty(groups = OnCreate.class) // required on create
private List<OrderItemRequest> items;
}
Specify the group in the controller:
@PostMapping
public ResponseEntity<OrderResponse> create(
@RequestBody @Validated(OnCreate.class) OrderRequest request) { ... }
@PutMapping("/{id}")
public ResponseEntity<OrderResponse> update(
@PathVariable UUID id,
@RequestBody @Validated(OnUpdate.class) OrderRequest request) { ... }
Note: use @Validated (Spring’s annotation) instead of @Valid when specifying groups.
Validating @PathVariable and @RequestParam
For method parameters that aren’t request bodies, add @Validated to the controller class:
@RestController
@RequestMapping("/api/orders")
@Validated // enables constraint annotations on method parameters
public class OrderController {
@GetMapping("/{id}")
public OrderResponse getOrder(
@PathVariable @NotNull UUID id) { ... }
@GetMapping
public List<OrderResponse> listOrders(
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { ... }
}
When a constraint fails here, Spring throws ConstraintViolationException (not MethodArgumentNotValidException).
Programmatic Validation
Sometimes you need to validate an object outside a controller:
@Service
@RequiredArgsConstructor
public class OrderImportService {
private final Validator validator;
public ImportResult importOrders(List<CreateOrderRequest> orders) {
List<ImportError> errors = new ArrayList<>();
for (int i = 0; i < orders.size(); i++) {
Set<ConstraintViolation<CreateOrderRequest>> violations =
validator.validate(orders.get(i));
if (!violations.isEmpty()) {
errors.add(new ImportError(i, violations.stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.toList()));
}
}
return new ImportResult(orders.size() - errors.size(), errors);
}
}
Inject jakarta.validation.Validator — Spring Boot auto-configures it.
Custom Error Messages
In-annotation messages
@NotBlank(message = "Customer name cannot be blank")
@Size(max = 100, message = "Name cannot exceed {max} characters")
@Pattern(regexp = "[A-Za-z ]+", message = "Name can only contain letters and spaces")
String customerName;
Use {max}, {min}, {value} to reference annotation attributes in the message.
Message interpolation from a file
Create src/main/resources/ValidationMessages.properties:
order.customerId.required=Customer ID is required
order.items.empty=Order must have at least one item
order.currency.invalid='{validatedValue}' is not a valid currency code
Reference in annotations:
@NotNull(message = "{order.customerId.required}")
UUID customerId;
@NotEmpty(message = "{order.items.empty}")
List<OrderItemRequest> items;
This is better for localization — you can provide different files for different locales.
Testing Validation
Test your DTOs in isolation — no Spring context needed:
class CreateOrderRequestTest {
private final ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
private final Validator validator = factory.getValidator();
@Test
void shouldFailWhenCustomerIdIsNull() {
var request = new CreateOrderRequest(
null, // customerId
List.of(new OrderItemRequest(UUID.randomUUID(), 1)),
validAddress()
);
Set<ConstraintViolation<CreateOrderRequest>> violations =
validator.validate(request);
assertThat(violations).hasSize(1);
assertThat(violations.iterator().next().getPropertyPath().toString())
.isEqualTo("customerId");
}
@Test
void shouldPassWithValidRequest() {
var request = new CreateOrderRequest(
UUID.randomUUID(),
List.of(new OrderItemRequest(UUID.randomUUID(), 2)),
validAddress()
);
assertThat(validator.validate(request)).isEmpty();
}
}
Test through MockMvc for integration:
@WebMvcTest(OrderController.class)
class OrderControllerValidationTest {
@Autowired MockMvc mockMvc;
@MockBean OrderService orderService;
@Autowired ObjectMapper mapper;
@Test
void shouldReturn400WhenCustomerIdMissing() throws Exception {
var request = Map.of("items", List.of(Map.of("productId", UUID.randomUUID(), "quantity", 1)));
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
}
What You’ve Learned
- Add
spring-boot-starter-validationand annotate DTO fields with constraint annotations @Validon a controller parameter triggers validation; cascade to nested objects with@Validon the field- Write custom constraint annotations with a
ConstraintValidatorimplementation - Cross-field validation goes on the class level with a type-level constraint
- Use groups (
@Validated(OnCreate.class)) for different rules on create vs update @Validatedon the controller class enables constraints on@PathVariableand@RequestParam- Test constraints in isolation with the
Validator— no Spring context needed
Next: Article 12 — Global Exception Handling — catch MethodArgumentNotValidException and all other errors in one place, return structured RFC 7807 ProblemDetail responses.