Micronaut Validation

Validation support for Micronaut

Version: 4.6.1-SNAPSHOT

1 Introduction

Micronaut Validation is a customizable validation solution for your applications.

Micronaut Validation was moved to a separate module for Micronaut version 4.0.0. For previous versions of Micronaut, refer to the Micronaut User Guide.

2 Release History

For this project, you can find a list of releases (with release notes) here:

3 Quick Start

To use the Micronaut’s validation capabilities you must have the validation dependency on your classpath:

annotationProcessor("io.micronaut.validation:micronaut-validation-processor")
<annotationProcessorPaths>
    <path>
        <groupId>io.micronaut.validation</groupId>
        <artifactId>micronaut-validation-processor</artifactId>
    </path>
</annotationProcessorPaths>

implementation("io.micronaut.validation:micronaut-validation")
<dependency>
    <groupId>io.micronaut.validation</groupId>
    <artifactId>micronaut-validation</artifactId>
</dependency>

You can validate types, fields and parameters by applying jakarta.validation annotations to arguments. The jakarta.validation-api library exposes those annotations, but it unnecessary to specify it as a direct dependency. It is included transitively when using micronaut-validation.

Supported Features

Note that Micronaut’s implementation is not currently fully compliant with the Bean Validator specification as the specification heavily relies on reflection-based APIs.

The following features are unsupported at this time:

  • Any interaction with the constraint metadata API, since Micronaut uses compile-time generated metadata.

  • XML-based configuration

  • Instead of using jakarta.validation.ConstraintValidator, use ConstraintValidator (io.micronaut.validation.validator.constraints.ConstraintValidator) to define custom constraints, which supports validating annotations at compile time.

Micronaut’s implementation includes the following benefits:

  • Reflection and Runtime Proxy free validation, resulting in reduced memory consumption

  • Smaller JAR size since Hibernate Validator adds another 1.4MB

  • Faster startup since Hibernate Validator adds 200ms+ startup overhead

  • Configurability via Annotation Metadata

  • Support for Reactive Bean Validation

  • Support for validating the source AST at compile time

  • Automatic compatibility with GraalVM native without additional configuration

If you require full Bean Validator 2.0 compliance, add the micronaut-hibernate-validator module to your build, which replaces Micronaut’s implementation.

implementation("io.micronaut.beanvalidation:micronaut-hibernate-validator")
<dependency>
    <groupId>io.micronaut.beanvalidation</groupId>
    <artifactId>micronaut-hibernate-validator</artifactId>
</dependency>

4 Validating Bean Methods

You can validate methods of any class declared as a Micronaut bean by applying jakarta.validation annotations to arguments:

Validating Methods
import jakarta.inject.Singleton;

import jakarta.validation.constraints.NotBlank;

@Singleton
public class PersonService {
    public void sayHello(@NotBlank String name) {
        System.out.println("Hello " + name);
    }
}
Validating Methods
import jakarta.inject.Singleton
import jakarta.validation.constraints.NotBlank

@Singleton
class PersonService {
    void sayHello(@NotBlank String name) {
        println "Hello $name"
    }
}
Validating Methods
import jakarta.inject.Singleton
import jakarta.validation.constraints.NotBlank

@Singleton
open class PersonService {
    open fun sayHello(@NotBlank name: String) {
        println("Hello $name")
    }
}

The above example declares that the @NotBlank annotation will be validated when invoking the sayHello method.

If you use Kotlin, the class and method must be declared open so Micronaut can create a compile-time subclass. Alternatively you can annotate the class with @Validated and configure the Kotlin all-open plugin to open classes annotated with this type. See the Compiler plugins section.

A jakarta.validation.ConstraintViolationException is thrown if a validation error occurs. For example:

ConstraintViolationException Example
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;

import jakarta.validation.ConstraintViolationException;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

@MicronautTest
class PersonServiceSpec {

    @Inject PersonService personService;

    @Test
    void testThatNameIsValidated() {
        final ConstraintViolationException exception =
                assertThrows(ConstraintViolationException.class, () ->
                personService.sayHello("") // (1)
        );

        assertEquals("sayHello.name: must not be blank", exception.getMessage()); // (2)
    }
}
ConstraintViolationException Example
import io.micronaut.test.extensions.spock.annotation.MicronautTest
import spock.lang.Specification

import jakarta.inject.Inject
import jakarta.validation.ConstraintViolationException

@MicronautTest
class PersonServiceSpec extends Specification {

    @Inject PersonService personService

    void "test person name is validated"() {
        when:"The sayHello method is called with a blank string"
        personService.sayHello("") // (1)

        then:"A validation error occurs"
        def e = thrown(ConstraintViolationException)
        e.message == "sayHello.name: must not be blank" //  (2)
    }
}
ConstraintViolationException Example
import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import jakarta.inject.Inject
import jakarta.validation.ConstraintViolationException

@MicronautTest
class PersonServiceSpec {

    @Inject
    lateinit var personService: PersonService

    @Test
    fun testThatNameIsValidated() {
        val exception = assertThrows(ConstraintViolationException::class.java) {
            personService.sayHello("") // (1)
        }

        assertEquals("sayHello.name: must not be blank", exception.message) // (2)
    }
}
1 The method is called with a blank string
2 An exception occurs

5 Validating Data Classes

To validate data classes, e.g. POJOs (typically used in JSON interchange), the class must be annotated with @Introspected (see Micronaut Guide Introspection section) or, if the class is external, be imported by the @Introspected annotation.

POJO Validation Example
import io.micronaut.core.annotation.Introspected;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;

@Introspected
public class Person {

    private String name;

    @Min(18)
    private int age;

    @NotBlank
    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void setAge(int age) {
        this.age = age;
    }
}
POJO Validation Example
import io.micronaut.core.annotation.Introspected

import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank

@Introspected
class Person {

    @NotBlank
    String name

    @Min(18L)
    int age
}
POJO Validation Example
import io.micronaut.core.annotation.Introspected
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank

@Introspected
data class Person(
    @field:NotBlank var name: String,
    @field:Min(18) var age: Int
)
The @Introspected annotation can be used as a meta-annotation; common annotations like @jakarta.persistence.Entity are treated as @Introspected

The above example defines a Person class that has two properties (name and age) that have constraints applied. Note that in Java the annotations can be on the field or the getter, and with Kotlin data classes, the annotation should target the field.

To validate the class manually, inject an instance of Validator:

Manual Validation Example
@Inject
Validator validator;

@Test
void testThatPersonIsValidWithValidator() {
    Person person = new Person();
    person.setName("");
    person.setAge(10);

    final Set<ConstraintViolation<Person>> constraintViolations = validator.validate(person);  // (1)

    assertEquals(2, constraintViolations.size()); // (2)
}
Manual Validation Example
@Inject Validator validator

void "test person is validated with validator"() {
    when:"The person is validated"
    def constraintViolations = validator.validate(new Person(name: "", age: 10)) // (1)

    then:"A validation error occurs"
    constraintViolations.size() == 2 //  (2)
}
Manual Validation Example
@Inject
lateinit var validator: Validator

@Test
fun testThatPersonIsValidWithValidator() {
    val person = Person("", 10)
    val constraintViolations = validator.validate(person) // (1)

    assertEquals(2, constraintViolations.size) // (2)
}
1 The validator validates the person
2 The constraint violations are verified

Alternatively on Bean methods you can use jakarta.validation.Valid to trigger cascading validation:

ConstraintViolationException Example
@Singleton
public class PersonService {
    public void sayHello(@Valid Person person) {
        System.out.println("Hello " + person.getName());
    }
}
ConstraintViolationException Example
@Singleton
class PersonService {
    void sayHello(@Valid Person person) {
        println "Hello $person.name"
    }
}
ConstraintViolationException Example
@Singleton
open class PersonService {
    open fun sayHello(@Valid person: Person) {
        println("Hello ${person.name}")
    }
}

The PersonService now validates the Person class when invoked:

Manual Validation Example
@Inject
PersonService personService;

@Test
void testThatPersonIsValid() {
    Person person = new Person();
    person.setName("");
    person.setAge(10);

    final ConstraintViolationException exception =
        assertThrows(ConstraintViolationException.class, () ->
            personService.sayHello(person) // (1)
        );

    assertEquals(2, exception.getConstraintViolations().size()); // (2)
}
Manual Validation Example
@Inject PersonService personService

void "test person name is validated"() {
    when:"The sayHello method is called with an invalid person"
    personService.sayHello(new Person(name: "", age: 10)) // (1)

    then:"A validation error occurs"
    def e = thrown(ConstraintViolationException)
    e.constraintViolations.size() == 2 //  (2)
}
Manual Validation Example
@Inject
lateinit var personService: PersonService

@Test
fun testThatPersonIsValid() {
    val person = Person("", 10)
    val exception = assertThrows(ConstraintViolationException::class.java) {
        personService.sayHello(person) // (1)
    }

    assertEquals(2, exception.constraintViolations.size) // (2)
}
1 A validated method is invoked
2 The constraint violations are verified

You can validate values of Java iterables, like List, Set and Map by defining validation annotations on generic parameters.

Iterables Validation Example
@Singleton
public class BookInfoService {
    public void setBookAuthors(
        @NotBlank String bookName,
        List<@NotBlank String> authors // (1)
    ) {
        System.out.println("Set book authors for book " + bookName);
    }

    public void setBookSectionPages(
        @NotBlank String bookName,
        Map<@NotBlank String, @Min(1) Integer> sectionStartPages // (2)
    ) {
        System.out.println("Set the start pages for all sections of book " + bookName);
    }
}
Iterables Validation Example
@Singleton
class BookInfoService {
    void setBookAuthors(
        @NotBlank String bookName,
        List<@NotBlank String> authors // (1)
    ) {
        println("Set book authors for book " + bookName)
    }

    void setBookSectionPages(
        @NotBlank String bookName,
        Map<@NotBlank String, @Min(1) Integer> sectionStartPages // (2)
    ) {
        println("Set the start pages for all sections of book " + bookName)
    }
}
Iterables Validation Example
@Singleton
open class BookInfoService {
    open fun setBookAuthors(
        bookName: @NotBlank String,
        authors: List<@NotBlank String> // (1)
    ) {
        println("Set book authors for book $bookName")
    }

    open fun setBookSectionPages(
        bookName: @NotBlank String,
        sectionStartPages: Map<@NotBlank String, @Min(1) Int>  // (2)
    ) {
        println("Set the start pages for all sections of book $bookName")
    }
}
1 List items will be validated with given annotation
2 Both keys and values of Map will be validated
@Test
void testAuthorNamesAreValidated() {
    final List<String> authors = Arrays.asList("Me", "");

    final ConstraintViolationException exception =
            assertThrows(ConstraintViolationException.class, () ->
                    bookInfoService.setBookAuthors("My Book", authors)
            );

    assertEquals("setBookAuthors.authors[1]<list element>: must not be blank",
            exception.getMessage()); // (1)
}

@Test
void testSectionsAreValidated() {
    final Map<String, Integer> sectionStartPages = new HashMap<>();
    sectionStartPages.put("", 1);

    final ConstraintViolationException exception =
            assertThrows(ConstraintViolationException.class, () ->
                    bookInfoService.setBookSectionPages("My Book", sectionStartPages)
            );

    assertEquals("setBookSectionPages.sectionStartPages[]<map key>: must not be blank",
            exception.getMessage()); // (2)
}
void testAuthorNamesAreValidated() {
    given:
    List<String> authors = ["Me", ""]

    when:
    bookInfoService.setBookAuthors("My Book", authors)

    then:
    ConstraintViolationException exception = thrown()
    "setBookAuthors.authors[1]<list element>: must not be blank" == exception.message // (1)
}

void testSectionsAreValidated() {
    given:
    Map<String, Integer> sectionStartPages = new HashMap<>()
    sectionStartPages.put("", 1)

    when:
    bookInfoService.setBookSectionPages("My Book", sectionStartPages)

    then:
    ConstraintViolationException exception = thrown()
    "setBookSectionPages.sectionStartPages[]<map key>: must not be blank" == exception.message // (2)
}
@Test
fun testAuthorNamesAreValidated() {
    val authors: List<String> = mutableListOf("Me", "")
    val exception = Assertions.assertThrows(
        ConstraintViolationException::class.java
    ) { bookInfoService.setBookAuthors("My Book", authors) }
    Assertions.assertEquals(
        "setBookAuthors.authors[1]<list element>: must not be blank",
        exception.message
    ) // (1)
}

@Test
fun testSectionsAreValidated() {
    val sectionStartPages: MutableMap<String, Int> = HashMap()
    sectionStartPages[""] = 1
    val exception = Assertions.assertThrows(
        ConstraintViolationException::class.java
    ) {
        bookInfoService.setBookSectionPages(
            "My Book",
            sectionStartPages
        )
    }
    Assertions.assertEquals(
        "setBookSectionPages.sectionStartPages[]<map key>: must not be blank",
        exception.message
    ) // (2)
1 The violation on the 1-st index in List
2 The violation was found in Map’s key
This feature is not yet supported in Groovy and Kotlin

6 Validating Configuration Properties

You can also validate the properties of classes that are annotated with @ConfigurationProperties to ensure configuration is correct.

It is recommended that you annotate @ConfigurationProperties that features validation with @Context to ensure that the validation occurs at startup.

7 Defining Additional Constraints

To define additional constraints, create a new annotation, for example:

Example Constraint Annotation
import jakarta.validation.Constraint;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
import static java.lang.annotation.ElementType.CONSTRUCTOR;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.ElementType.TYPE_USE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Retention(RUNTIME)
@Constraint(validatedBy = { MyValidatorBean.class }) // (1)
public @interface DurationPattern {

    String message() default "invalid duration ({validatedValue})"; // (2)

    /**
     * Defines several constraints on the same element.
     */
    @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
    @Retention(RUNTIME)
    @Documented
    @interface List {
        DurationPattern[] value(); // (3)
    }
}
Example Constraint Annotation
import jakarta.validation.Constraint
import java.lang.annotation.Retention

import static java.lang.annotation.RetentionPolicy.RUNTIME

@Retention(RUNTIME)
@Constraint(validatedBy = [ MyValidatorBean ]) // (1)
@interface DurationPattern {
    String message() default "invalid duration ({validatedValue})" // (2)
}
Example Constraint Annotation
import jakarta.validation.Constraint
import kotlin.annotation.AnnotationRetention.RUNTIME

@Retention(RUNTIME)
@Constraint(validatedBy = []) // (1)
annotation class DurationPattern(
    val message: String = "invalid duration ({validatedValue})" // (2)
)
1 The annotation should be annotated with jakarta.validation.Constraint
2 A message template can be provided in a hard-coded manner as above. If none is specified, Micronaut tries to find a message using ClassName.message using the MessageSource interface (optional)
3 To support repeated annotations you can define an inner annotation (optional)
You can add messages and message bundles using the MessageSource and ResourceBundleMessageSource classes. See Resource Bundles documentation.
A constraint with an empty validatedBy = {} will use the bean context to find a bean of ConstraintValidator with a generic value of your annotation, or it’s possible to set validatedBy = MyConstraintValidator.class and in this case the validator can be an introspected bean or a simple bean in loaded from the bean context.

Once you have defined the annotation, implement a ConstraintValidator that validates the annotation. You can either create a bean class that implements the interface directly or define a factory that returns one or more validators.

The former approach can be taken for single bean:

Example Constraint Validator Bean
import io.micronaut.core.annotation.AnnotationValue;
import io.micronaut.core.annotation.Introspected;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.validation.validator.constraints.ConstraintValidator;
import io.micronaut.validation.validator.constraints.ConstraintValidatorContext;
import jakarta.inject.Singleton;

@Singleton
@Introspected
public class MyValidatorBean implements ConstraintValidator<DurationPattern, Object> {

    @Override
    public boolean isValid(@Nullable Object value,
                           @NonNull AnnotationValue<DurationPattern> annotationMetadata,
                           @NonNull ConstraintValidatorContext context) {
        context.messageTemplate("invalid duration ({validatedValue}), additional custom message"); // (1)
        return value == null || value.toString().matches("^PT?[\\d]+[SMHD]{1}$");
    }
}
Example Constraint Validator Bean
import io.micronaut.core.annotation.AnnotationValue
import io.micronaut.core.annotation.Introspected
import io.micronaut.core.annotation.NonNull
import io.micronaut.core.annotation.Nullable
import io.micronaut.validation.validator.constraints.ConstraintValidator
import io.micronaut.validation.validator.constraints.ConstraintValidatorContext
import jakarta.inject.Singleton

@Singleton
@Introspected
class MyValidatorBean implements ConstraintValidator<DurationPattern, Object> {

    @Override
    boolean isValid(@Nullable Object value,
                    @NonNull AnnotationValue<DurationPattern> annotationMetadata,
                    @NonNull ConstraintValidatorContext context) {
        context.messageTemplate("invalid duration ({validatedValue}), additional custom message") // (1)
        return value == null || value.toString() ==~ /^PT?[\d]+[SMHD]{1}$/
    }
}
Example Constraint Validator Bean
import io.micronaut.core.annotation.AnnotationValue
import io.micronaut.core.annotation.Introspected
import io.micronaut.validation.validator.constraints.ConstraintValidator
import io.micronaut.validation.validator.constraints.ConstraintValidatorContext
import jakarta.inject.Singleton

@Singleton
@Introspected
class MyValidatorBean : ConstraintValidator<DurationPattern, Any> {

    override fun isValid(value: Any?,
                         annotationMetadata: AnnotationValue<DurationPattern>,
                         context: ConstraintValidatorContext) : Boolean {
        context.messageTemplate("invalid duration ({validatedValue}), additional custom message") // (1)
        return value == null || value.toString().matches("^PT?[\\d]+[SMHD]{1}$".toRegex())
    }
}
1 Override the default message template with an inline call for more control over the validation error message. (Since 2.5.0)

The latter approach is recommended if you plan to define multiple validators:

Example Constraint Validator Factory
import io.micronaut.context.annotation.Factory;
import io.micronaut.validation.validator.constraints.ConstraintValidator;
import jakarta.inject.Singleton;

@Factory
public class MyValidatorFactory {

    @Singleton
    ConstraintValidator<DurationPattern, Object> durationPatternValidator() {
        return (value, annotationMetadata, context) -> {
            context.messageTemplate("invalid duration ({validatedValue}), additional custom message"); // (1)
            return value == null || value.toString().matches("^PT?[\\d]+[SMHD]{1}$");
        };
    }
}
Example Constraint Validator Factory
import io.micronaut.context.annotation.Factory
import io.micronaut.core.annotation.AnnotationValue
import io.micronaut.validation.validator.constraints.ConstraintValidator
import io.micronaut.validation.validator.constraints.ConstraintValidatorContext

import jakarta.inject.Singleton

@Factory
class MyValidatorFactory {

    @Singleton
    ConstraintValidator<DurationPattern, CharSequence> durationPatternValidator() {
        return { CharSequence value,
                 AnnotationValue<DurationPattern> annotation,
                 ConstraintValidatorContext context ->
            context.messageTemplate("invalid duration ({validatedValue}), additional custom message") // (1)
            return value == null || value.toString() ==~ /^PT?[\d]+[SMHD]{1}$/
        } as ConstraintValidator<DurationPattern, CharSequence>
    }
}
Example Constraint Validator Factory
import io.micronaut.context.annotation.Factory
import io.micronaut.validation.validator.constraints.ConstraintValidator
import jakarta.inject.Singleton

@Factory
class MyValidatorFactory {

    @Singleton
    fun durationPatternValidator() : ConstraintValidator<DurationPattern, Any> {
        return ConstraintValidator { value, _, context ->
            context.messageTemplate("invalid duration ({validatedValue}), additional custom message") // (1)
            value == null || value.toString().matches("^PT?[\\d]+[SMHD]{1}$".toRegex())
        }
    }
}
1 Override the default message template with an inline call for more control over the validation error message. (Since 2.5.0)

The above example implements a validator that validates any field, parameter etc. that is annotated with DurationPattern, ensuring that the string can be parsed with java.time.Duration.parse.

Generally null is regarded as valid and @NotNull is used to constrain a value as not being null. The example above regards null as a valid value.

For example:

Example Custom Constraint Usage
@Singleton
public class HolidayService {

    @Executable
    public String startHoliday(@NotBlank String person,
                               @DurationPattern String duration) {
        final Duration d = Duration.parse(duration);
        return "Person " + person + " is off on holiday for " + d.toMinutes() + " minutes";
    }

    public String startHoliday(@DurationPattern String fromDuration, @DurationPattern String toDuration, @NotBlank String person
    ) {
        final Duration d = Duration.parse(fromDuration);
        final Duration e = Duration.parse(toDuration);
        return "Person " + person + " is off on holiday from " + d + " to " + e;
    }
}
Example Custom Constraint Usage
@Singleton
class HolidayService {

    String startHoliday(@NotBlank String person,
                        @DurationPattern String duration) {
        final Duration d = Duration.parse(duration)
        return "Person $person is off on holiday for ${d.toMinutes()} minutes"
    }
}
Example Custom Constraint Usage
@Singleton
open class HolidayService {

    open fun startHoliday(@NotBlank person: String,
                          @DurationPattern duration: String): String {
        val d = Duration.parse(duration)
        return "Person $person is off on holiday for ${d.toMinutes()} minutes"
    }
}

To verify the above examples validates the duration parameter, define a test:

Testing Example Custom Constraint Usage
@Inject HolidayService holidayService;

@Test
void testCustomValidator() {
    final ConstraintViolationException exception =
        assertThrows(ConstraintViolationException.class, () ->
            holidayService.startHoliday("Fred", "junk") // (1)
        );

    assertEquals("startHoliday.duration: invalid duration (junk), additional custom message", exception.getMessage()); // (2)
}

// Issue:: micronaut-core/issues/6519
@Test
void testCustomAndDefaultValidator() {
    final ConstraintViolationException exception =
            assertThrows(ConstraintViolationException.class, () ->
                    holidayService.startHoliday( "fromDurationJunk", "toDurationJunk", "")
            );

    String notBlankValidated = exception.getConstraintViolations().stream().filter(constraintViolation -> Objects.equals(constraintViolation.getPropertyPath().toString(), "startHoliday.person")).map(ConstraintViolation::getMessage).findFirst().get();
    String fromDurationPatternValidated = exception.getConstraintViolations().stream().filter(constraintViolation -> Objects.equals(constraintViolation.getPropertyPath().toString(), "startHoliday.fromDuration")).map(ConstraintViolation::getMessage).findFirst().get();
    String toDurationPatternValidated = exception.getConstraintViolations().stream().filter(constraintViolation -> Objects.equals(constraintViolation.getPropertyPath().toString(), "startHoliday.toDuration")).map(ConstraintViolation::getMessage).findFirst().get();
    assertEquals("must not be blank", notBlankValidated);
    assertEquals("invalid duration (fromDurationJunk), additional custom message", fromDurationPatternValidated);
    assertEquals("invalid duration (toDurationJunk), additional custom message", toDurationPatternValidated);
}
Testing Example Custom Constraint Usage
void "test test custom validator"() {
    when:"A custom validator is used"
    holidayService.startHoliday("Fred", "junk") // (1)

    then:"A validation error occurs"
    def e = thrown(ConstraintViolationException)
    e.message == "startHoliday.duration: invalid duration (junk), additional custom message" //  (2)
}
Testing Example Custom Constraint Usage
@Inject
lateinit var holidayService: HolidayService

@Test
fun testCustomValidator() {
    val exception = assertThrows(ConstraintViolationException::class.java) {
        holidayService.startHoliday("Fred", "junk") // (1)
    }

    assertEquals("startHoliday.duration: invalid duration (junk), additional custom message", exception.message) // (2)
}
1 A validated method is invoked
2 THe constraint violations are verified
See the guide for Custom Constraint Annotation for Validation to learn more.

8 Validating Annotations at Compile Time

Micronaut Validation validates annotation elements at compile time with micronaut-validation-processor in the annotation processor classpath:

annotationProcessor("io.micronaut.validation:micronaut-validation-processor")
<annotationProcessorPaths>
    <path>
        <groupId>io.micronaut.validation</groupId>
        <artifactId>micronaut-validation-processor</artifactId>
    </path>
</annotationProcessorPaths>

Micronaut Validation will, at compile time, validate annotation values that are themselves annotated with jakarta.validation. For example consider the following annotation:

Annotation Validation
import java.lang.annotation.Retention;

import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Retention(RUNTIME)
public @interface TimeOff {
    @DurationPattern
    String duration();
}
Annotation Validation
import java.lang.annotation.Retention

import static java.lang.annotation.RetentionPolicy.RUNTIME

@Retention(RUNTIME)
@interface TimeOff {
    @DurationPattern
    String duration()
}
Annotation Validation
import kotlin.annotation.AnnotationRetention.RUNTIME

@Retention(RUNTIME)
annotation class TimeOff(
    @DurationPattern val duration: String
)

If you attempt to use @TimeOff(duration="junk") in your source, Micronaut will fail compilation due to the duration value violating the DurationPattern constraint.

If duration is a property placeholder such as @TimeOff(duration="${my.value}"), validation is deferred until runtime.

Note that to use a custom ConstraintValidator at compile time you must instead define the validator as a class:

Example Constraint Validator
import io.micronaut.core.annotation.AnnotationValue;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.validation.validator.constraints.ConstraintValidator;
import io.micronaut.validation.validator.constraints.ConstraintValidatorContext;

public class DurationPatternValidator implements ConstraintValidator<DurationPattern, CharSequence> {
    @Override
    public boolean isValid(
            @Nullable CharSequence value,
            @NonNull AnnotationValue<DurationPattern> annotationMetadata,
            @NonNull ConstraintValidatorContext context) {
        return value == null || value.toString().matches("^PT?[\\d]+[SMHD]{1}$");
    }
}
Example Constraint Validator
import io.micronaut.core.annotation.NonNull
import io.micronaut.core.annotation.Nullable
import io.micronaut.core.annotation.AnnotationValue
import io.micronaut.validation.validator.constraints.ConstraintValidator
import io.micronaut.validation.validator.constraints.ConstraintValidatorContext

class DurationPatternValidator implements ConstraintValidator<DurationPattern, CharSequence> {
    @Override
    boolean isValid(
            @Nullable CharSequence value,
            @NonNull AnnotationValue<DurationPattern> annotationMetadata,
            @NonNull ConstraintValidatorContext context) {
        return value == null || value.toString() ==~ /^PT?[\d]+[SMHD]{1}$/
    }
}
Example Constraint Validator
import io.micronaut.core.annotation.AnnotationValue
import io.micronaut.validation.validator.constraints.ConstraintValidator
import io.micronaut.validation.validator.constraints.ConstraintValidatorContext

class DurationPatternValidator : ConstraintValidator<DurationPattern, CharSequence> {
    override fun isValid(
        value: CharSequence?,
        annotationMetadata: AnnotationValue<DurationPattern>,
        context: ConstraintValidatorContext): Boolean {
        return value == null || value.toString().matches("^PT?[\\d]+[SMHD]{1}$".toRegex())
    }
}

Additionally:

  • Define a META-INF/services/io.micronaut.validation.validator.constraints.ConstraintValidator file that references the class.

  • The class must be public and have a public no-argument constructor

  • The class must be on the annotation processor classpath of the project to be validated.

9 Repository

You can find the source code of this project in this repository: