Master Java Bean Validation
Craft Smarter Constraints with Annotation Composition
Java Bean Validation (JSR 380) empowers developers to enforce data integrity with declarative constraints like @NotNull
and @Size
. But complex validation logic often requires combining constraints or creating domain-specific rules.
In this story, we’ll explore annotation composition, cross-field validation, and message customization—culminating in a challenge to design a @ValidPhoneNumber
meta-annotation.
Non-members can read full story here (FREE) for limited time: Master Java Bean Validation
1. Meta-Annotations: Bundle Constraints for Cleaner Code
Meta-annotations combine multiple constraints into a reusable, domain-specific annotation, reducing boilerplate and ensuring consistency. For example, a reusable @ValidEmail
annotation:
@NotNull
@Size(max = 100)
@Email(regexp = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$")
@Target({FIELD, PARAMETER})
@Retention(RUNTIME)
@Documented
public @interface ValidEmail {
String message() default "Invalid email address";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
Usage:
public class User {
@ValidEmail
private String email;
}
Why It Rocks:
- Reusability: Apply across classes or modules.
- Centralized Logic: Update constraints in one place.
2. Cross-Field Validation with @ScriptAssert
Need to validate relationships between fields, like ensuring password
matches confirmPassword
? Use @ScriptAssert
with a Groovy script:
@ScriptAssert(
lang = "groovy",
script = "_this.password == _this.confirmPassword",
message = "Passwords must match"
)
public class SignupRequest {
private String password;
private String confirmPassword;
}
Limitations:
- Pro: Simple for basic cross-field checks.
- Con: Class-level only, and scripts can impact performance. For complex logic, custom validators offer better testability and speed.
3. Class-Level Validation with @Validated
For multi-field validation, like ensuring startDate
is before endDate
, create a custom class-level constraint:
Example: Ensure startDate
< endDate
:
@Target(TYPE)
@Retention(RUNTIME)
@Constraint(validatedBy = DateRangeValidator.class)
@Documented
public @interface ValidDateRange {
String message() default "Start date must precede end date";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class DateRangeValidator implements ConstraintValidator<ValidDateRange, Event> {
@Override
public boolean isValid(Event event, ConstraintValidatorContext context) {
return event.getStartDate().isBefore(event.getEndDate());
}
}
Usage:
@ValidDateRange
public class Event {
private LocalDate startDate;
private LocalDate endDate;
}
4. Customize Messages with Interpolation
Make error messages user-friendly by externalizing them in ValidationMessages.properties. Override default messages using ValidationMessages.properties
:
# Custom email message
ValidEmail.message=Invalid email: must be under {max} characters and match pattern.
# Cross-field
SignupRequest.password.mismatch=Password and confirmation do not match.
# Parameterized
field.invalid.length=Length must be at least {min}.
Parameterized Messages:
@Size(min = 8, message = "{field.invalid.length}")
private String password;
Pro Tip: Parameterized messages keep your validation logic DRY and maintainable.
Challenge: Compose a @ValidPhoneNumber
Annotation
Let’s put your skills to the test! Create a @ValidPhoneNumber
annotation with these requirements:
- Country Code: Starts with
+
followed by 1-3 digits. - Digit Length: Total length 10–15 characters (including
+
), digits only after the country code.. - Custom Message: “Invalid phone number”.
Step 1: Define the Meta-Annotation
To ensure a single, unified error message (instead of multiple messages from @NotBlank
, @Pattern
, and @Size
), we’ll use a custom validator:
@NotBlank
@Pattern(regexp = "^\\+\\d{1,3}\\d{6,11}$")
@Size(min = 10, max = 15)
@Target({FIELD, PARAMETER})
@Retention(RUNTIME)
@Constraint(validatedBy = ValidPhoneNumberValidator.class)
@Documented
public @interface ValidPhoneNumber {
String message() default "Invalid phone number";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class ValidPhoneNumberValidator implements ConstraintValidator<ValidPhoneNumber, String> {
private static final Pattern PATTERN = Pattern.compile("^\\+\\d{1,3}\\d{6,11}$");
@Override
public boolean isValid(String phoneNumber, ConstraintValidatorContext context) {
if (phoneNumber == null || phoneNumber.isBlank()) {
return false;
}
if (phoneNumber.length() < 10 || phoneNumber.length() > 15) {
return false;
}
return PATTERN.matcher(phoneNumber).matches();
}
}
Why a Custom Validator?
- Combines checks for nullity, length, and format.
- Returns a single “Invalid phone number” message, avoiding confusion from multiple constraint violations.
Alternative Approach: If you prefer simplicity, you can skip the custom validator and rely on the composed annotations (@NotBlank
, @Pattern
, @Size
). However, this may produce multiple error messages if multiple constraints fail.
Step 2: Test Cases
Here’s a comprehensive test suite to validate @ValidPhoneNumber
:
public class PhoneValidationTest {
private Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
@Test
void validPhoneNumber() {
User user = new User("+1234567890"); // 10 chars
Set<ConstraintViolation<User>> violations = validator.validate(user);
assertTrue(violations.isEmpty());
}
@Test
void validPhoneNumber_MaxLength() {
User user = new User("+12345678901234"); // 15 chars
Set<ConstraintViolation<User>> violations = validator.validate(user);
assertTrue(violations.isEmpty());
}
@Test
void invalidPhoneNumber_NoCountryCode() {
User user = new User("1234567890");
Set<ConstraintViolation<User>> violations = validator.validate(user);
assertEquals(1, violations.size());
assertEquals("Invalid phone number", violations.iterator().next().getMessage());
}
@Test
void invalidPhoneNumber_TooShort() {
User user = new User("+1234567"); // 8 chars
Set<ConstraintViolation<User>> violations = validator.validate(user);
assertEquals(1, violations.size());
}
@Test
void invalidPhoneNumber_NonDigits() {
User user = new User("+123456abc");
Set<ConstraintViolation<User>> violations = validator.validate(user);
assertEquals(1, violations.size());
}
@Test
void invalidPhoneNumber_TooLong() {
User user = new User("+123456789012345"); // 16 chars
Set<ConstraintViolation<User>> violations = validator.validate(user);
assertEquals(1, violations.size());
}
}
Test Coverage:
- Valid cases: Minimum (10 chars) and maximum (15 chars) lengths.
- Invalid cases: Missing country code, too short, non-digits, and too long.
- Ensures the custom validator’s unified error message appears.
Share Your Solution:
We want to see your @ValidPhoneNumber
implementation! Post your code and test cases on X with the hashtag #ValidationChallenge #bytewise010. Answer these questions in your post:
- Did you use a custom validator for message unification, or stick with composed annotations?
- How did you craft your regex to balance flexibility and strictness?
Key Takeaways
- Meta-Annotations: Bundle constraints for reusable, DRY code, but watch for multiple error messages without a custom validator.
- Cross-Field Logic: Use
@ScriptAssert
or custom class-level constraints. - Messages: Externalize and parameterize for maintainability.
Next Steps:
- Explore for advanced features.
- Integrate with Spring’s
@Validated
for method-level validation. - Try integrating your
@ValidPhoneNumber
in a Spring Boot app and share your results!
Your Turn:
Implement @ValidPhoneNumber
, run your tests, and share your solution on X with #ValidationChallenge #bytewise010. Whether you went with a custom validator or a regex-heavy approach, we’re excited to see your creativity!
Got questions? Drop them in the comments or tag us on X.