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.
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 —
@Validon 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.
@Validatedon a class enables method validation, which protects services called from anywhere.
The constraint vocabulary
| Category | Constraints |
|---|---|
| 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.
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:
@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
@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.
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:
@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:
@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
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?
What is the difference between @Valid and @Validated?
Should I validate in the DTO or in the domain model?
Related tutorials
- Redis with Spring BootRedis beyond caching: choosing the right data structure, distributed locks that are actually safe, Redis Streams as a queue, and configuring Lettuce for Sentinel and Cluster.
- Logging & Debugging in ProductionLogging that helps at 3am: choosing levels that mean something, MDC correlation IDs across threads, structured JSON output, async appenders, and what must never be logged.
- Spring Data JPA Deep DiveEntity mapping that scales: relationship pitfalls, diagnosing and fixing the N+1 problem, derived queries versus Specifications, pagination that stays fast, and JPA auditing.
- File Handling & Object StorageHandling uploads and downloads safely: multipart limits, detecting real content types with Tika, streaming large files, S3 and MinIO integration, and presigned URLs.