Custom constraint annotation for validation

How to create a custom constraint annotation for validation in your Micronaut application

Authors: Sergio del Amo

Micronaut Version: 4.3.7

1. Getting Started

In this guide, we will create a Micronaut application written in Java.

2. What you will need

To complete this guide, you will need the following:

3. Solution

We recommend that you follow the instructions in the next sections and create the application step by step. However, you can go right to the completed example.

4. Writing the Application

Create an application using the Micronaut Command Line Interface or with Micronaut Launch.

mn create-app example.micronaut.micronautguide \
    --features=junit-params,validation \
    --build=gradle \
    --lang=java \
    --test=junit
If you don’t specify the --build argument, Gradle is used as the build tool.
If you don’t specify the --lang argument, Java is used as the language.
If you don’t specify the --test argument, JUnit is used for Java and Kotlin, and Spock is used for Groovy.

The previous command creates a Micronaut application with the default package example.micronaut in a directory named micronautguide.

If you use Micronaut Launch, select Micronaut Application as application type and add junit-params, and validation features.

If you have an existing Micronaut application and want to add the functionality described here, you can view the dependency and configuration changes from the specified features and apply those changes to your application.

5. Writing the application

In this guide, you will code an annotation - @E164 - to validate phone numbers.

E.164 is the international telephone numbering plan that ensures each device on the PSTN has globally unique number.

This number allows phone calls and text messages can be correctly routed to individual phones in different countries. E.164 numbers are formatted [+] [country code] [subscriber number including area code] and can have a maximum of fifteen digits.

5.1. Country Code

Create an enum for the country code. The following enum maps the ITU_T recommendation for E.164 assigned country codes.

src/main/java/example/micronaut/CountryCode.java
package example.micronaut;

import io.micronaut.core.annotation.Nullable;
import jakarta.annotation.Nonnull;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
 * Every country code in the world.
 * @see <a href="https://www.itu.int/dms_pub/itu-t/opb/sp/T-SP-E.164D-11-2011-PDF-E.pdf">LIST OF ITU-T RECOMMENDATION E.164 ASSIGNED COUNTRY CODES</a>
 */
public enum CountryCode {

    AFGHANISTAN("93", "Afghanistan"),
    ALBANIA("355", "Albania (Republic of)"),
    ALGERIA("213", "Algeria (People's Democratic Republic of)"),
    AMERICAN_SAMOA("1", "American Samoa"),
    ANDORRA("376", "Andorra (Principality of)"),
    ANGOLA("244", "Angola (Republic of)"),
    ANGUILLA("1", "Anguilla"),
    ANTIGUA_AND_BARBUDA("1", "Antigua and Barbuda"),
    ARGENTINA("54", "Argentine Republic"),
    ARMENIA("374", "Armenia (Republic of)"),
    ARUBA("297", "Aruba"),
    AUSTRALIA("61", "Australia"),
    AUSTRALIAN_EXTERNAL_TERRITORIES("672", "Australian External Territories"),
    AUSTRIA("43", "Austria"),
    AZERBAIJAN("994", "Azerbaijan (Republic of)"),
    BAHAMAS("1", "Bahamas (Commonwealth of the)"),
    BAHRAIN("973", "Bahrain (Kingdom of)"),
    BANGLADESH("880", "Bangladesh (People's Republic of)"),
    BARBADOS("1", "Barbados"),
    BELARUS("375", "Belarus (Republic of)"),
    BELGIUM("32", "Belgium"),
    BELIZE("501", "Belize"),
    BENIN("229", "Benin (Republic of)"),
    BERMUDA("1", "Bermuda"),
    BHUTAN("975", "Bhutan (Kingdom of)"),
    BOLIVIA("591", "Bolivia (Plurinational State of)"),
    BONAIRE_SINT_EUSTATIUS_AND_SABA("599", "Bonaire, Sint Eustatius and Saba"),
    BOSNIA_AND_HERZEGOVINA("387", "Bosnia and Herzegovina"),
    BOTSWANA("267", "Botswana (Republic of)"),
    BRAZIL("55", "Brazil (Federative Republic of)"),
    BRITISH_VIRGIN_ISLANDS("1", "British Version Islands"),
    BRUNEI("673", "Brunei Darussalam"),
    BULGARIA("359", "Bulgaria (Republic of)"),
    BURKINA_FASO("226", "Burkina Faso"),
    BURUNDI("257", "Burundi (Republic of)"),
    CABO_VERDE("238", "Cabo Verde (Republic of)"),
    CAMBODIA("855", "Cambodia (Kingdom of"),
    CAMEROON("237", "Cameroon (Republic of)"),
    CANADA("1", "Canada"),
    CAYMAN_ISLANDS("1", "Cayman Islands"),
    CENTRAL_AFRICAN_REPUBLIC("236", "Central African Republic"),
    CHAD("235", "Chad (Republic of)"),
    CHILE("56", "Chile"),
    CHINA("86", "China (People's Republic of)"),
    COLOMBIA("57", "Colombia (Republic of)"),
    COMOROS("269", "Comoros (Union of the)"),
    CONGO("242", "Congo (Republic of the)"),
    COOK_ISLANDS("682", "Cook Islands"),
    COSTA_RICA("506", "Costa Rica"),
    CROATIA("385", "Croatia (Republic of)"),
    CUBA("53", "Cuba"),
    CURACAO("599", "Curacao"),
    CYPRUS("357", "Cyprus (Republic of)"),
    CZECH_REPUBLIC("420", "Czech Republic"),
    DEMOCRATIC_REPUBLIC_OF_THE_CONGO("243", "Democratic Republic of the Congo"),
    DENMARK("45", "Denmark"),
    DISASTER_RELIEF("888", "Telecommunications for Disaster Relief (TDR)"),
    DJIBOUTI("253", "Djibouti (Republic of)"),
    DOMINICA("1", "Dominica (Commonwealth of)"),
    DOMINICAN_REPUBLIC("1", "Dominican Republic"),
    EAST_TIMOR("670", "Timor-Leste (Democratic Republic of)"),
    ECUADOR("593", "Ecuador"),
    EGYPT("20", "Egypt (Arab Republic of)"),
    EL_SALVADOR("503", "El Salvador (Republic of)"),
    EQUATORIAL_GUINEA("240", "Equatorial Guinea (Republic of)"),
    ERITREA("291", "Eritrea"),
    ESTONIA("372", "Estonia (Republic of)"),
    ETHIOPIA("251", "Ethiopia (Federal Democratic Republic of)"),
    FALKLAND_ISLANDS("500", "Falkland Islands (Malvinas)"),
    FAROE_ISLANDS("298", "Faroe Islands"),
    FIJI("679", "Fiji (Republic of"),
    FINLAND("358", "Finland"),
    FRANCE("33", "France"),
    FRENCH_GUIANA("590", "French Guiana (French Department of)"),
    FRENCH_POLYNESIA("689", "French Polynesia"),
    GABON("241", "Gabonese Republic"),
    GAMBIA("220", "Gambia (Republic of)"),
    GEORGIA("995", "Georgia"),
    GERMANY("49", "Germany (Federal Republic of)"),
    GHANA("233", "Ghana"),
    GIBRALTAR("350", "Gibraltar"),
    GMSS("881", "Global Missile Satellite System (GMSS), shared code"),
    GREECE("30", "Greece"),
    GREENLAND("299", "Greenland (Denmark)"),
    GRENADA("1", "Grenada"),
    GROUP_SHARED("388", "Group of countries, shared code"),
    GUADELOUPE("590", "Guadeloupe (French Department of"),
    GUAM("1", "Guam"),
    GUATEMALA("502", "Guatemala (Republic of)"),
    GUINEA("224", "Guinea (Republic of)"),
    GUINEA_BISSAU("245", "Guinnea-Bassau (Republic of)"),
    GUYANA("592", "Guyana"),
    HAITI("509", "Haiti (Republic of)"),
    HONDURAS("504", "Honduras (Republic of)"),
    HONG_KONG("852", "Hong Kong, China"),
    HUNGARY("36", "Hungary (Republic of)"),
    ICELAND("354", "Iceland"),
    INDIA("91", "India (Republic of)"),
    INDONESIA("62", "Indonesia"),
    INMARSAT("870", "Inmarsat SNAC"),
    INTERNATIONAL_FREEPHONE("800", "International Freephone Service"),
    INTERNATIONAL_NETWORKS("882", "International Networks, shared code"), //There is a second entry for 883 with the same name.
    INTERNATIONAL_PREMIUM("979", "International Premium Rate Service (IPRS)"),
    INTERNATIONAL_SHARED("808", "International Shared Cost Service (ISCS)"),
    INTERNATIONAL_TRIAL("991", "Trial of a proposed new international telecommunication public correspondence service, shared code"),
    IRAN("98", "Iran (Islamic Republic of)"),
    IRAQ("964", "Iraq (Republic of)"),
    IRELAND("353", "Ireland"),
    ISRAEL("972", "Israel (State of)"),
    ITALY("39", "Italy"),
    IVORY_COAST("225", "Cote d'Ivoire (Republic of)"),
    JAMAICA("1", "Jamaica"),
    JAPAN("81", "Japan"),
    JORDAN("962", "Jordan (Hashemite Kingdom of)"),
    KAZAKHSTAN("7", "Kazakhstan (Republic of)"),
    KENYA("254", "Kenya (Republic of)"),
    KIRIBATI("686", "Kiribati (Republic of)"),
    KOSOVO("383", "Kosovo"),
    KUWAIT("965", "Kuwait (State of)"),
    KYRGYZSTAN("996", "Kyrgyz Republic"),
    LAOS("856", "Lao People's Democratic Republic"),
    LATVIA("371", "Latvia (Republic of)"),
    LEBANON("961", "Lebanon"),
    LESOTHO("266", "Lesotho (Kingdom of)"),
    LIBERIA("231", "Liberia (Republic of)"),
    LIBYA("218", "Libya"),
    LIECHTENSTEIN("423", "Liechtenstein (Principality of)"),
    LITHUANIA("370", "Lithuania (Republic of)"),
    LUXEMBOURG("352", "Luxembourg"),
    MACAO("853", "Macao, China"),
    MACEDONIA("389", "The Former Yugoslav Republic of Macedonia"),
    MADAGASCAR("261", "Madagascar (Republic of)"),
    MALAWI("265", "Malawi"),
    MALAYSIA("60", "Malaysia"),
    MALDIVES("960", "Maldives (Republic of)"),
    MALI("223", "Mali (Republic of)"),
    MALTA("356", "Malta"),
    MARSHALL_ISLANDS("692", "Marshall Islands (Republic of)"),
    MARTINIQUE("596", "Martinique (French Department of"),
    MAURITANIA("222", "Mauritania (Islamic Republic of)"),
    MAURITIUS("230", "Mauritius (Republic of)"),
    MEXICO("52", "Mexico"),
    MICRONESIA("691", "Micronesia (Federated States of)"),
    MOLDOVA("373", "Moldova (Republic of)"),
    MONACO("377", "Monaco (Principality of)"),
    MONGOLIA("976", "Mongolia"),
    MONTENEGRO("382", "Montenegro (Republic of)"),
    MONTSERRAT("1", "Montserrat"),
    MOROCCO("212", "Morocco (Kingdom of)"),
    MOZAMBIQUE("258", "Mozambique (Republic of)"),
    MYANMAR("95", "Myanmar (The Republic of the Union of)"),
    NAMIBIA("264", "Namibia (Republic of)"),
    NAURU("674", "Nauru (Republic of)"),
    NEPAL("977", "Nepal (Federal Democratic Republic of)"),
    NETHERLANDS("31", "Netherlands (Kingdom of the)"),
    NEW_CALEDONIA("687", "New Caledonia (Territoire francais d'outre-mer)"),
    NEW_ZEALAND("64", "New Zealand"),
    NICARAGUA("505", "Nicaragua"),
    NIGER("227", "Niger (Republic of)"),
    NIGERIA("234", "Nigeria (Federal Republic of)"),
    NIUE("683", "Niue"),
    NORTH_KOREA("850", "Democratic People's Republic of Korea\n"),
    NORTHERN_MARIANA_ISLANDS("1", "Northern Mariana Islands (Commonwealth of the)"),
    NORWAY("47", "Norway"),
    OMAN("968", "Oman (Sultanate of)"),
    PAKISTAN("92", "Pakistan (Islamic Republic of)"),
    PALAU("680", "Palau (Republic of)"),
    PANAMA("507", "Panama (Republic of)"),
    PAPUA_NEW_GUINEA("675", "Papua New Guinea"),
    PARAGUAY("595", "PARAGUAY (Republic of)"),
    PERU("51", "Peru"),
    PHILIPPINES("63", "Philippines (Republic of the)"),
    POLAND("48", "Poland (Republic of)"),
    PORTUGAL("351", "Portugal"),
    PUERTO_RICO("1", "Puerto Rico"),
    QATAR("974", "Qatar (State of)"),
    REUNION("262", "French Departments and Territories in the Indian Ocean"),
    ROMANIA("40", "Romania"),
    RUSSIA("7", "Russian Federation"),
    RWANDA("250", "Rwanda (Republic of)"),
    SAINT_HELENA("290", "Saint Helena, Ascension and the Tristan da Cunha"), //Also, 247 has an identical entry
    SAINT_KITTS_AND_NEVIS("1", "Saint Kitts and Nevis"),
    SAINT_LUCIA("1", "Saint Lucia"),
    SAINT_PIERRE_AND_MIQUELON("508", "Saint Pierre and Miquelon (Collectivite territoriale de la Republique francaise)"),
    SAINT_VINCENT_AND_THE_GRENADINES("1", "Saint Vincent and the Grenadines"),
    SAMOA("685", "Samoa (Independent State of"),
    SAN_MARINO("378", "San Marino (Republic of)"),
    SAO_TOME_AND_PRINCIPE("239", "Sao Tome and Principe (Democratic Republic of)"),
    SAUDI_ARABIA("966", "Saudi Arabia (Kingdom of)"),
    SENEGAL("221", "Senegal (Republic of)"),
    SERBIA("381", "Serbia (Republic of)"),
    SEYCHELLES("248", "Seychelles (Republic of)"),
    SIERRA_LEONE("232", "Sierra Leone"),
    SINGAPORE("65", "Singapore (Republic of)"),
    SINT_MAARTEN("1", "Sint Maarten (Dutch part)"),
    SLOVAKIA("421", "Slovak Republic"),
    SLOVENIA("386", "Slovenia (Republic of)"),
    SOLOMON_ISLANDS("677", "Solomon Islands"),
    SOMALIA("252", "Somalia (Federal Republic of)"),
    SOUTH_AFRICA("27", "South Africa (Republic of)"),
    SOUTH_KOREA("82", "Korea (Republic of)"),
    SOUTH_SUDAN("211", "South Sudan (Republic of)"),
    SPAIN("34", "Spain"),
    SRI_LANKA("94", "Sri Lanka (Democratic Socialist Republic of)"),
    SUDAN("249", "Sudan (Republic of)"),
    SURINAME("597", "Suriname (Republic of"),
    SWAZILAND("268", "Swaziland (Kingdom of)"),
    SWEDEN("46", "Sweden"),
    SWITZERLAND("41", "Switzerland (Confederation of)"),
    SYRIA("963", "Syrian Arab Republic"),
    TAIWAN("886", "Taiwan, China"), //Republic of China?
    TAJIKISTAN("992", "Tajikstan (Republic of)"),
    TANZANIA("255", "Tanzania (United Republic of)"),
    THAILAND("66", "Thailand"),
    TOGO("228", "Togolese Republic"),
    TOKELAU("690", "Tokelau"),
    TONGA("676", "Tonga (Kingdom of)"),
    TRINIDAD_AND_TOBAGO("1", "Trinidad and Tobago"),
    TUNISIA("216", "Tunisia"),
    TURKEY("90", "Turkey"),
    TURKMENISTAN("993", "Turkemenistan"),
    TURKS_AND_CAICOS_ISLANDS("1", "Turks and Caicos Islands"),
    TUVALU("688", "Tuvalu"),
    UGANDA("256", "Uganda (Republic of)"),
    UKRAINE("380", "Ukraine"),
    UNITED_ARAB_EMIRATES("971", "United Arab Emirates"),
    UNITED_KINGDOM("44", "United Kingdom of Great Britain and Northern Ireland"),
    UNITED_STATES("1", "United States of America"),
    UPT("878", "Universal Personal Telecommunication Service (UPT)"),
    URUGUAY("598", "Uruguay (Eastern Republic of)"),
    UZBEKISTAN("998", "Uzbekistan (Republic of)"),
    VANUATU("678", "Vanuatu (Republic of)"),
    VATICAN("379", "Vatican City State"),
    VENEZUELA("58", "Venezuala (Bolivarian Republic of)"),
    VIETNAM("84", "Viet nam (Socialist Republic of)"),
    WALLIS_AND_FUTUNA("681", "Wallis and Futuna (Territoire francais d'outre-mer)"),
    YEMEN("967", "Yemen (Republic of)"),
    ZAMBIA("260", "Zambia (Republic of)"),
    ZIMBABWE("263", "Zimbabwe (Republic of)");

    private final String code;
    private final String countryName;

    /**
     * Constructor for countries whose name does not match the enum.
     * @param code country code
     * @param countryName full country name
     */
    CountryCode(String code, String countryName) {
        this.code = code;
        this.countryName = countryName;
    }

    public String getCode() {
        return this.code;
    }

    /**
     * Country name.
     * @return country name
     */
    public String getCountryName() {
        return this.countryName;
    }

    private static final String PLUS_SIGN = "+";

    private static final Map<String, List<CountryCode>> COUNTRYCODESBYCODE =
            Arrays.stream(CountryCode.values())
                    .collect(Collectors.groupingBy(CountryCode::getCode));

    private static final List<String> CODES = COUNTRYCODESBYCODE.keySet().stream()
            .sorted(Comparator.comparing(String::length).reversed()).toList();

    /**
     *
     * @param code Country code
     * @return a List of {@link CountryCode} for a found code or an empty list
     */
    @NotNull
    @Nonnull
    public static List<CountryCode> countryCodesByCode(@Nonnull @NotBlank String code) {
        if (COUNTRYCODESBYCODE.containsKey(code)) {
            return COUNTRYCODESBYCODE.get(code);
        }
        return new ArrayList<>();
    }

    /**
     *
     * @return Country codes ordered from codes of longer length to less length.
     */
    public static List<String> getCodes() {
        return CODES;
    }

    /**
     *
     * @param number Phone number
     * @return the Country code found in the phone number or {@code null} if not found.
     */
    @Nullable
    public static String parseCountryCode(@Nonnull @NotBlank String number) {
        String phone = number.startsWith(PLUS_SIGN) ? number.substring(1) : number;
        for (String code : getCodes()) {
            if (phone.startsWith(code)) {
                return code;
            }
        }
        return null;
    }

    @Override
    public String toString() {
        return this.code;
    }
}

And a test for this enum:

src/test/java/example/micronaut/CountryCodeTest.java
package example.micronaut;

import org.junit.jupiter.api.Test;

import java.util.Arrays;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

class CountryCodeTest {

    @Test
    void preferredNameGetsUsed() {
        String name = CountryCode.YEMEN.getCountryName();
        assertEquals(name, "Yemen (Republic of)");
    }

    @Test
    void defaultNameIsCapitalizedCorrectly() {
        String name = CountryCode.SPAIN.getCountryName();
        assertEquals(name, "Spain");
    }

    @Test
    void toStringReturnsCorrectValue() {
        String code = CountryCode.AMERICAN_SAMOA.toString();
        assertEquals(code, "1");
    }

    @Test
    void countryCodeGetCodesReturnEveryCodeWithLongestCodesFirst() {
        assertTrue(CountryCode.getCodes().get(0).length() > 1);
    }

    @Test
    void countryCodeParseCountryCodeParseCodes() {

        assertNull(CountryCode.parseCountryCode("999999"));

        assertEquals("34",CountryCode.parseCountryCode("34630443322"));
        assertEquals("268",CountryCode.parseCountryCode("2684046441"));
        assertEquals("1",CountryCode.parseCountryCode("+14155552671"));
    }

    @Test
    void countryCodeCountryCodesByCodeReturnAListOfCountryCodeWithTheSameCountryCode() {
        assertTrue(CountryCode.countryCodesByCode("999999").isEmpty());
        assertEquals(CountryCode.countryCodesByCode("1"), Arrays.asList(
                CountryCode.AMERICAN_SAMOA,
                CountryCode.ANGUILLA,
                CountryCode.ANTIGUA_AND_BARBUDA,
                CountryCode.BAHAMAS,
                CountryCode.BARBADOS,
                CountryCode.BERMUDA,
                CountryCode.BRITISH_VIRGIN_ISLANDS,
                CountryCode.CANADA,
                CountryCode.CAYMAN_ISLANDS,
                CountryCode.DOMINICA,
                CountryCode.DOMINICAN_REPUBLIC,
                CountryCode.GRENADA,
                CountryCode.GUAM,
                CountryCode.JAMAICA,
                CountryCode.MONTSERRAT,
                CountryCode.NORTHERN_MARIANA_ISLANDS,
                CountryCode.PUERTO_RICO,
                CountryCode.SAINT_KITTS_AND_NEVIS,
                CountryCode.SAINT_LUCIA,
                CountryCode.SAINT_VINCENT_AND_THE_GRENADINES,
                CountryCode.SINT_MAARTEN,
                CountryCode.TRINIDAD_AND_TOBAGO,
                CountryCode.TURKS_AND_CAICOS_ISLANDS,
                CountryCode.UNITED_STATES));
    }
}

6. Phone E164

Create a Utils class to validate a phone number.

src/main/java/example/micronaut/E164Utils.java
package example.micronaut;

import io.micronaut.core.annotation.Nullable;
import io.micronaut.core.util.StringUtils;

/**
 * Utility methods to ease {@link E164} validation.
 */
public final class E164Utils {

    private static final int MAX_NUMBER_OF_DIGITS = 15;
    private static final String PLUS_SIGN = "+";

    private E164Utils() {
    }

    /**
     * @param value phone number
     * @return Whether a phone is E.164 formatted
     */
    public static boolean isValid(@Nullable String value) {
        if (value == null || value.isEmpty()) {
            return false;
        }

        String phone = value.startsWith(PLUS_SIGN) ? value.substring(1) : value;
        if (phone.length() > MAX_NUMBER_OF_DIGITS) {
            return false;
        }
        if (phone.isEmpty()) {
            return false;
        }
        if (!StringUtils.isDigits(phone) || phone.charAt(0) == '0') {
            return false;
        }

        return CountryCode.parseCountryCode(phone) != null;
    }
}

and a test for valid and invalid phones:

src/test/java/example/micronaut/E164UtilsTest.java
package example.micronaut;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

class E164UtilsTest {

    @ParameterizedTest
    @ValueSource(strings = {
            "+04630443322",
            "+1415555267102345",
            "+1-4155552671",
            ""
    })
    void invalidPhones(String phone) {
        assertFalse(E164Utils.isValid(phone));
    }

    @ParameterizedTest
    @ValueSource(strings = {
            "+14155552671",
            "+442071838750",
            "+55115525632",
            "14155552671",
            "442071838750",
            "55115525632",
            "55115525632",
    })
    void validPhones(String phone) {
        assertTrue(E164Utils.isValid(phone));
    }
}

7. Custom Validation Annotation

Create a custom annotation:

src/main/java/example/micronaut/E164.java
package example.micronaut;

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

/**
 * The annotated element must be a E.164 phone number.
 *
 * @see <a href="https://www.itu.int/rec/T-REC-E.164/en">ITU E.164 recommendation</a>
 * @see <a href="https://www.twilio.com/docs/glossary/what-e164">E.614</a>
 */
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(E164.List.class)
@Documented
@Constraint(validatedBy = {})
public @interface E164 {

    String MESSAGE = "example.micronaut.E164.message";

    /**
     * @return message The error message
     */
    String message() default "{" + MESSAGE + "}";

    /**
     * @return Groups to control the order in which constraints are evaluated,
     * or to perform validation of the partial state of a JavaBean.
     */
    Class<?>[] groups() default {};

    /**
     * @return Payloads used by validation clients to associate some metadata information with a given constraint declaration
     */
    Class<? extends Payload>[] payload() default {};

    /**
     * List annotation.
     */
    @Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.PARAMETER, ElementType.TYPE_USE})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    @interface List {

        /**
         * @return An array of E164.
         */
        E164[] value();
    }
}

8. Validation Factory

Create a factory that creates a ConstraintValidator for the annotation defined in the previous step.

src/main/java/example/micronaut/CustomValidationFactory.java
package example.micronaut;

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

@Factory (1)
class CustomValidationFactory {

    /**
     * @return A {@link ConstraintValidator} implementation of a {@link E164} constraint for type {@link String}.
     */
    @Singleton (2)
    ConstraintValidator<E164, String> e164Validator() {
        return (value, annotationMetadata, context) -> E164Utils.isValid(value);
    }
}
1 A class annotated with the @Factory annotated is a factory. It provides one or more methods annotated with a bean scope annotation (e.g. @Singleton). Read more about Bean factories.
2 Use jakarta.inject.Singleton to designate a class as a singleton.

9. Validation Messages

Create a default message for the E164 constraint:

src/main/java/example/micronaut/CustomValidationMessages.java
package example.micronaut;

import io.micronaut.context.StaticMessageSource;
import jakarta.inject.Singleton;

/**
 * Adds validation messages.
 */
@Singleton
public class CustomValidationMessages extends StaticMessageSource {

    public static final String E164_MESSAGE = "must be a phone in E.164 format";
    /**
     * The message suffix to use.
     */
    private static final String MESSAGE_SUFFIX = ".message";

    /**
     * Default constructor to initialize messages.
     * via {@link #addMessage(String, String)}
     */
    public CustomValidationMessages() {
        addMessage(E164.class.getName() + MESSAGE_SUFFIX, E164_MESSAGE);
    }
}

10. Testing Validation

Create a Contact object which uses the custom annotation.

src/main/java/example/micronaut/Contact.java
package example.micronaut;

import io.micronaut.core.annotation.Introspected;
import io.micronaut.core.annotation.NonNull;

import jakarta.validation.constraints.NotBlank;

@Introspected
public class Contact {

    @E164
    @NotBlank
    @NonNull
    private final String phone;

    public Contact(@NonNull String phone) {
        this.phone = phone;
    }


    @NonNull
    public String getPhone() {
        return phone;
    }
}

Create a test verifies the custom annotation participates in the validation of the object.

src/test/java/example/micronaut/ContactTest.java
package example.micronaut;

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

import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;

import java.util.Set;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

@MicronautTest(startApplication = false) (1)
class ContactTest {

    @Inject (2)
    Validator validator;

    @Test
    void contactValidation() {
        assertTrue(validator.validate(new Contact("+14155552671")).isEmpty());
        Set<ConstraintViolation<Contact>> violationSet = validator.validate(new Contact("+1-4155552671"));
        assertFalse(violationSet.isEmpty());
        String template = "{example.micronaut.E164.message}";
        assertTrue(violationSet.stream().anyMatch(violation ->
                violation.getMessageTemplate().equals(template)
                        && violation.getInvalidValue().equals("+1-4155552671")
                        && violation.getMessage().equals("must be a phone in E.164 format"))
        );
    }
}
1 Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context. This test does not need the embedded server. Set startApplication to false to avoid starting it.
2 Injection for Validator.

11. Next steps

Explore more features with Micronaut Guides.

Learn about Bean Validation and how to localize your application.

12. Help with the Micronaut Framework

The Micronaut Foundation sponsored the creation of this Guide. A variety of consulting and support services are available.

13. License

All guides are released with an Apache license 2.0 license for the code and a Creative Commons Attribution 4.0 license for the writing and media (images…​).