Sitemap

Master Java Bean Validation

Craft Smarter Constraints with Annotation Composition

4 min readApr 21, 2025

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.

Photo by on

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:

  1. Country Code: Starts with + followed by 1-3 digits.
  2. Digit Length: Total length 10–15 characters (including +), digits only after the country code..
  3. 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

  1. Meta-Annotations: Bundle constraints for reusable, DRY code, but watch for multiple error messages without a custom validator.
  2. Cross-Field Logic: Use @ScriptAssert or custom class-level constraints.
  3. Messages: Externalize and parameterize for maintainability.

Next Steps:

  1. Explore for advanced features.
  2. Integrate with Spring’s @Validated for method-level validation.
  3. 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.

No responses yet