Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Validation & Data Integrity

Jakarta Bean Validation in Spring Boot: the full constraint set, custom validators, validation groups, cross-field rules, method validation and where each layer belongs.

Beginner7 min readUpdated
On this page

Validation is cheap to add and expensive to skip. Jakarta Bean Validation gives you declarative constraints that produce structured, field-level errors — and Spring wires it into controllers, service methods and JPA persistence with almost no code.

Key Takeaways

  • Constraints are declarative and composable; a custom one is an annotation plus a validator class.
  • Cascading is explicit@Valid on the field, and on the element type for collections.
  • Groups let one object be validated differently on create and on update.
  • Cross-field rules belong in a class-level validator, not smeared across two field annotations.
  • @Validated on a class enables method validation, which protects services called from anywhere.

The constraint vocabulary

CategoryConstraints
Presence@NotNull, @NotBlank, @NotEmpty, @Null
Size@Size, @Min, @Max, @DecimalMin, @DecimalMax, @Digits
Sign@Positive, @PositiveOrZero, @Negative, @NegativeOrZero
Text@Email, @Pattern, @URL
Time@Past, @PastOrPresent, @Future, @FutureOrPresent
Boolean@AssertTrue, @AssertFalse

The three presence constraints are not interchangeable, and mixing them up is the most common mistake. @NotNull allows an empty string. @NotEmpty requires length greater than zero but allows " ". @NotBlank requires at least one non-whitespace character. For a user-supplied name you almost always want @NotBlank.

CreateOrderRequest.java
public record CreateOrderRequest(
 
    @NotBlank(message = "customerId is required")
    String customerId,
 
    @NotEmpty(message = "an order needs at least one line")
    @Size(max = 100, message = "an order cannot exceed 100 lines")
    List<@Valid OrderLineRequest> lines,     // @Valid on the ELEMENT type
 
    @Valid                                   // cascade into the nested object
    AddressRequest shippingAddress,
 
    @FutureOrPresent(message = "delivery date cannot be in the past")
    LocalDate requestedDelivery
) { }
 
public record OrderLineRequest(
    @NotBlank @Pattern(regexp = "[A-Z]{3}-\\d{4}", message = "must look like ABC-1234")
    String sku,
 
    @Positive @Max(999)
    int quantity
) { }

Trigger it in the controller with @Valid, and a failure raises MethodArgumentNotValidException which your @ControllerAdvice turns into a field-level error response:

OrderController.java
@PostMapping("/api/v1/orders")
public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) {
    var order = orderService.place(request);
    return ResponseEntity.created(URI.create("/api/v1/orders/" + order.id())).body(order);
}

A custom constraint

ValidSku.java
@Documented
@Constraint(validatedBy = SkuValidator.class)
@Target({ ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT })
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidSku {
    String message() default "unknown or discontinued SKU";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
 
public class SkuValidator implements ConstraintValidator<ValidSku, String> {
 
    private final CatalogClient catalog;   // validators are Spring beans, so injection works
 
    public SkuValidator(CatalogClient catalog) {
        this.catalog = catalog;
    }
 
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        // Null handling is @NotNull's job, not ours. Returning true here keeps
        // the constraints orthogonal and composable.
        if (value == null) return true;
 
        return catalog.findStatus(value)
                .map(status -> {
                    if (status == SkuStatus.DISCONTINUED) {
                        context.disableDefaultConstraintViolation();
                        context.buildConstraintViolationWithTemplate(
                                "SKU %s was discontinued".formatted(value))
                               .addConstraintViolation();
                        return false;
                    }
                    return true;
                })
                .orElse(false);
    }
}

Injecting a client into a validator is legitimate but has a cost: validation now makes a network call, and it runs before your controller. Cache the lookup, give it a timeout, and consider whether the check belongs in the service layer instead. A validator that can time out turns a 422 into a 500.

Validation groups

The same object often has different rules depending on the operation. An id must be absent on create and present on update.

ProductPayload.java
public interface OnCreate { }
public interface OnUpdate { }
 
public record ProductPayload(
    @Null(groups = OnCreate.class, message = "id must not be supplied on create")
    @NotNull(groups = OnUpdate.class, message = "id is required on update")
    Long id,
 
    @NotBlank(groups = { OnCreate.class, OnUpdate.class })
    String name,
 
    @NotNull(groups = OnCreate.class)
    BigDecimal price
) { }
 
@PostMapping
public ProductResponse create(@Validated(OnCreate.class) @RequestBody ProductPayload payload) { }
 
@PutMapping("/{id}")
public ProductResponse update(@Validated(OnUpdate.class) @RequestBody ProductPayload payload) { }

Note the switch from @Valid to @Validated — groups are a Spring feature. One subtlety worth remembering: constraints with no explicit group belong to Default, and naming a specific group means Default constraints are not evaluated unless you include it.

Cross-field rules

A constraint that compares two fields cannot live on either of them. Put it on the type:

PasswordsMatch.java
@Constraint(validatedBy = PasswordsMatchValidator.class)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface PasswordsMatch {
    String message() default "passwords do not match";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
 
public class PasswordsMatchValidator
        implements ConstraintValidator<PasswordsMatch, RegistrationRequest> {
 
    @Override
    public boolean isValid(RegistrationRequest req, ConstraintValidatorContext ctx) {
        if (req.password() == null || req.confirmPassword() == null) return true;
        if (req.password().equals(req.confirmPassword())) return true;
 
        // Attach the violation to a specific field so the client can highlight it.
        ctx.disableDefaultConstraintViolation();
        ctx.buildConstraintViolationWithTemplate(ctx.getDefaultConstraintMessageTemplate())
           .addPropertyNode("confirmPassword")
           .addConstraintViolation();
        return false;
    }
}

addPropertyNode is the detail that makes the difference between a form that highlights the wrong field and one that highlights the right one.

Method validation

@Valid on a controller parameter protects the HTTP entry point. It does nothing for a service method called from a scheduled job or a message listener. @Validated at class level closes that gap:

PricingService.java
@Service
@Validated
public class PricingService {
 
    public Money quote(@NotBlank String sku,
                       @Positive int quantity,
                       @Valid PricingContext context) {
        return engine.calculate(sku, quantity, context);
    }
 
    public @NotNull Money floorPrice(@NotBlank String sku) {
        return repository.floor(sku);
    }
}

Violations here raise ConstraintViolationException rather than MethodArgumentNotValidException, so add a handler for it in your @ControllerAdvice or these surface as 500s.

Writing messages people can act on

A validation message is read by a developer integrating against your API, often at the exact moment they are already frustrated. Three properties make the difference between a helpful message and a useless one.

State what was expected, not what was wrong. "must be greater than 0" tells the caller the rule; "invalid quantity" tells them nothing they did not already know from the status code. Where the rule has a format, show it: "must look like ABC-1234" saves a round trip to the documentation.

Name the field, and let the error structure carry it rather than embedding it in prose. A client that wants to highlight the offending input needs the field name as data, not as a substring it has to parse out of an English sentence. That is why the errors array in an RFC 7807 body carries field, message and rejectedValue as separate keys.

Avoid leaking internals. A message that says "failed CHECK constraint ck_order_qty_positive" exposes your schema and helps nobody. Translate database and library errors into the same vocabulary your API constraints use.

For applications serving multiple locales, externalise messages instead of hard-coding them. Put the key in the annotation — @Positive(message = "{order.quantity.positive}") — and the translations in ValidationMessages.properties per locale. Spring resolves them through the configured MessageSource, and the Accept-Language header selects the right one.

Validation and idempotency

There is a category of rule that looks like validation and is not: uniqueness. Checking that an email address is unused before inserting is a read followed by a write, and between the two, another request can insert the same address. The check passes, the insert fails, and the user sees a 500 instead of a clear message.

The reliable pattern inverts the order. Attempt the write, let the database's unique constraint reject the duplicate, catch DataIntegrityViolationException and translate it into the same field-level 422 a validator would have produced. The pre-check is still worth keeping as a fast path for the common case, but it can never be the thing you rely on.

The same reasoning applies to any rule whose truth depends on data another transaction can change — stock levels, account balances, seat availability. These are business invariants enforced by the database or by a lock, and treating them as validation produces code that is correct in tests and racy in production.

Where each layer belongs

Four layers, four different jobs. Each catches what the ones above it cannot.

These are not redundant. DTO constraints give a good error message; domain constructors guarantee an object cannot exist in an invalid state regardless of the code path; database constraints hold even when a migration script or a second application writes to the same table. Skipping the database layer because "the application validates it" works right up until something else touches the data.

What to take away

Validate at the edge for good error messages, in the domain for correctness, and in the database for durability. Keep custom validators null-tolerant so they compose, put cross-field rules at class level with an explicit property node, and remember that @Valid does not cascade unless you tell it to.

Frequently Asked Questions

Why is @Valid on my nested object being ignored?
Validation does not cascade automatically. The field holding the nested object needs its own @Valid annotation, and for collections the annotation goes on the element type — List<@Valid LineItem> — so each element is validated rather than just the list reference.
What is the difference between @Valid and @Validated?
@Valid is the Jakarta standard and triggers cascading validation. @Validated is Spring specific and adds validation groups plus class-level method validation. Use @Valid on request bodies and nested fields; use @Validated on a class when you want constraints on method parameters enforced.
Should I validate in the DTO or in the domain model?
Both, for different reasons. DTO constraints reject malformed input at the edge with a helpful field-level message. Domain invariants belong in the domain object constructor so they hold no matter which code path created it. The DTO protects the API contract; the constructor protects correctness.

Related tutorials