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

AnnotationValidates
@NotNullValue is not null
@NotEmptyString/collection not null and not empty
@NotBlankString not null, not empty, not just whitespace
@Size(min, max)String length or collection size
@Min(value)Number ≥ value
@Max(value)Number ≤ value
@PositiveNumber > 0
@PositiveOrZeroNumber ≥ 0
@NegativeNumber < 0
@Pattern(regexp)String matches regex
@EmailValid email format
@PastDate is in the past
@FutureDate is in the future
@DecimalMin(value)Decimal ≥ value (as string)
@AssertTrueBoolean is true
@AssertFalseBoolean 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-validation and annotate DTO fields with constraint annotations
  • @Valid on a controller parameter triggers validation; cascade to nested objects with @Valid on the field
  • Write custom constraint annotations with a ConstraintValidator implementation
  • 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
  • @Validated on the controller class enables constraints on @PathVariable and @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.