The full picture
public record CreateAccountRequest(
@NotBlank @Email String email,
@NotNull @Past LocalDate birthDate,
@Valid @NotNull AddressDto address, // cascades into nested object
@StrongPassword String password // custom constraint
) {}
@PostMapping("/accounts")
AccountDto create(@Valid @RequestBody CreateAccountRequest req) { … }
// Custom constraint = annotation + validator:
@Constraint(validatedBy = StrongPasswordValidator.class)
@Target(FIELD) @Retention(RUNTIME)
public @interface StrongPassword {
String message() default "password too weak";
Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {};
}
class StrongPasswordValidator implements ConstraintValidator<StrongPassword, String> {
public boolean isValid(String v, ConstraintValidatorContext c) {
return v != null && v.length() >= 12 && hasMixedClasses(v);
}
}@Valid(Jakarta) triggers validation & cascades;@Validated(Spring) adds groups and enables method validation on non-controller beans.- Validation groups (e.g.,
OnCreate,OnUpdate) solve 'id required on update, forbidden on create' — though separate request records per operation is often the cleaner answer. - Cross-field rules: class-level constraints (
@PasswordsMatchon the record) or explicit code; don't contort field-level annotations. - Business rules as annotations (
@UniqueEmailhitting the DB inside a validator) are a smell: hidden I/O, no transaction context, surprises under retry. Keep I/O-dependent rules in the service where they're testable and transactional.