NeuroAgent

How to Create a Custom Annotation in Jakarta Validation

Complete guide to creating custom annotations with dynamic error messages in Jakarta Validation and Spring Boot

How can I create a custom annotation in Jakarta Validation with dynamic error messages similar to @Size?

I’m working with Jakarta Validation and would like to create a custom annotation similar to @Size. For example, for a String field:

java
public class RegForm {
    @Size(min=3, max=20)
    private String login;
}

When validation fails by default, it outputs a message containing the min and max attribute values:
“size must be between 3 and 20”

Looking at the source code, the message attribute of the annotation looks like this:

java
String message() default "{jakarta.validation.constraints.Size.message}";

This means the default message should be in MessageSource. I’m using Spring Boot, but I couldn’t find similar functionality in its autoconfiguration. I’m also unclear about how the min and max values get into MessageSource.

Could you please explain how to create a custom annotation whose message can vary depending on the annotation attribute values?

NeuroAgent

Creating Custom Jakarta Validation Annotations with Dynamic Error Messages

To create your own Jakarta Validation annotation with dynamic error messages, you’ll need to create three components: an annotation, a validator, and a message configuration. The system uses message interpolation, where parameters in curly braces are automatically replaced with annotation attribute values.

Table of Contents

Jakarta Validation Basics and Message Interpolation

Jakarta Validation uses a message interpolation mechanism to create dynamic error messages. When validation fails with an error, the system automatically substitutes annotation attribute values into the message template.

The interpolation process works as follows:

  1. Finds the message template based on the key (e.g., {jakarta.validation.constraints.Size.message})
  2. Replaces parameters in curly braces with values from corresponding annotation attributes
  3. Supports international localization through MessageSource

For the @Size annotation, the system automatically substitutes the min and max values into the message template. You can use the same principles for your own annotations.

Creating a Custom Annotation

Let’s create a @CustomSize annotation that will work similarly to @Size, but with the ability to use custom messages.

java
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = CustomSizeValidator.class)
public @interface CustomSize {
    String message() default "{com.example.validation.CustomSize.message}";
    
    Class<?>[] groups() default {};
    
    Class<? extends Payload>[] payload() default {};
    
    int min() default 0;
    
    int max() default Integer.MAX_VALUE;
}

Key points:

  • @Constraint specifies the validator class
  • message() uses a template with a key for interpolation
  • min() and max() define the value range
  • The annotation can be applied to fields and method parameters

Implementing ConstraintValidator

Now let’s create a validator that will check values and pass parameters to the interpolation system:

java
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.springframework.beans.BeanWrapperImpl;

public class CustomSizeValidator implements ConstraintValidator<CustomSize, CharSequence> {
    private int min;
    private int max;
    
    @Override
    public void initialize(CustomSize constraintAnnotation) {
        this.min = constraintAnnotation.min();
        this.max = constraintAnnotation.max();
    }
    
    @Override
    public boolean isValid(CharSequence value, ConstraintValidatorContext context) {
        if (value == null) {
            return true; // null values are validated by @NotNull annotation
        }
        
        int length = value.length();
        
        if (length < min || length > max) {
            // Add parameters for message interpolation
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate(
                context.getDefaultConstraintMessageTemplate()
            )
            .addConstraintViolation()
            .addPropertyNode("value")
            .addDynamicValue("min", min)
            .addDynamicValue("max", max)
            .addConstraintViolation();
            
            return false;
        }
        
        return true;
    }
}

Important: The addDynamicValue() method adds values for interpolation parameters.

Message Configuration in Spring Boot

For message interpolation to work, you need to configure MessageSource. Create a message file:

messages.properties

com.example.validation.CustomSize.message=Size must be between {min} and {max} characters

messages_ru_RU.properties

com.example.validation.CustomSize.message=Размер должен быть между {min} и {max} символами

Add the configuration to Spring Boot:

java
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;

@Configuration
public class ValidationConfig {
    
    @Bean
    public MessageSource messageSource() {
        ReloadableResourceBundleMessageSource messageSource = 
            new ReloadableResourceBundleMessageSource();
        messageSource.setBasename("classpath:messages");
        messageSource.setDefaultEncoding("UTF-8");
        messageSource.setCacheSeconds(10);
        return messageSource;
    }
    
    @Bean
    public LocalValidatorFactoryBean validator() {
        LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean();
        bean.setValidationMessageSource(messageSource());
        return bean;
    }
}

Complete Implementation Example

Let’s create a complete example of using our annotation:

java
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class UserController {
    
    @PostMapping("/users")
    public ResponseEntity<String> createUser(@Valid @RequestBody UserDto user, BindingResult result) {
        if (result.hasErrors()) {
            // Handle validation errors
            return ResponseEntity.badRequest().body(result.getAllErrors().toString());
        }
        return ResponseEntity.ok("User created successfully");
    }
}

// DTO with our annotation
public class UserDto {
    @CustomSize(min=3, max=20)
    private String username;
    
    @CustomSize(min=6, max=30)
    private String password;
    
    // getters and setters
}

For testing, you can use the following service:

java
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;

@Service
@Validated
public class UserService {
    
    public void validateUsername(@CustomSize(min=3, max=20) String username) {
        // Method with parameter validation
        System.out.println("Username is valid: " + username);
    }
}

Advanced Features

Conditional Validation

You can add additional attributes for conditional validation:

java
@Constraint(validatedBy = CustomSizeValidator.class)
public @interface CustomSize {
    // ... existing attributes
    
    String message() default "{com.example.validation.CustomSize.message}";
    
    boolean includeMin() default true;
    
    boolean includeMax() default true;
}

Expression Language Interpolation

For complex scenarios, you can use Expression Language:

java
@Constraint(validatedBy = CustomSizeValidator.class)
public @interface CustomSize {
    String message() default "{com.example.validation.CustomSize.expression}";
    
    String expression() default "min == max ? 'must be equal to {min}' : 'must be between {min} and {max}'";
}

Custom Interpolation Logic

For complex scenarios, you can create a custom interpolator:

java
import jakarta.validation.MessageInterpolator;
import org.springframework.context.MessageSource;

public class CustomMessageInterpolator implements MessageInterpolator {
    private final MessageSource messageSource;
    
    public CustomMessageInterpolator(MessageSource messageSource) {
        this.messageSource = messageSource;
    }
    
    @Override
    public String interpolate(String messageTemplate, Context context) {
        // Your custom interpolation logic
        return messageSource.getMessage(messageTemplate, null, Locale.getDefault());
    }
    
    // ... other interface methods
}

Troubleshooting Common Problems

Problem: Parameters are not substituted in the message

Solution: Ensure that:

  1. The validator adds parameters via addDynamicValue()
  2. The message key matches the one specified in the annotation
  3. MessageSource is properly configured

Problem: Messages are not localized

Solution: Check:

  1. Message file names (messages_ru_RU.properties)
  2. UTF-8 encoding
  3. Correct locales in the application

Problem: Annotation doesn’t work in Spring Boot

Solution: Add the dependency to pom.xml:

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

And ensure the validator is registered as a bean.

Problem: Dynamic values are not passed

Solution: Use ConstraintValidatorContext to add dynamic values:

java
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
    "{com.example.validation.CustomSize.message}"
)
.addConstraintViolation()
.addDynamicValue("min", min)
.addDynamicValue("max", max);

Sources

  1. Configurable Model Validations with Jakarta Bean Validation, Spring Boot and Hibernate — Estafet
  2. Spring Boot Custom Bean Validations with Jakarta ConstraintValidator, Grouping Validation Constraints, GroupSequence and i18n messages · GitHub
  3. Java Bean Validation :: Spring Framework
  4. Spring Validation Message Interpolation | Baeldung
  5. Custom Validation in Spring Boot best explained – Part 1 – DevXperiences
  6. Hibernate Validator 9.0.1.Final - Jakarta Validation Reference
  7. Java Bean Validation with Javax/Jakarta Validation | Medium
  8. @Valid + Jakarta with DTO in Spring Boot 3 - DEV Community
  9. Custom Validation Messages in Spring Boot REST APIs | Medium
  10. Guide to Field Validation with Jakarta Validation in Spring | Medium

Conclusion

  • To create dynamic validation messages, use Jakarta Validation’s interpolation mechanism with parameters in curly braces
  • A custom annotation requires three components: the annotation itself, the validator, and the message configuration
  • Spring Boot simplifies configuration through automatic validation configuration and MessageSource
  • To pass dynamic values, use the addDynamicValue() method in ConstraintValidator
  • The system supports international localization through different property files

I recommend starting with the basic implementation as shown in the example and gradually adding more complex features as needed.