DEV Community

Cover image for Creating Custom Annotations for Validation in Spring Boot
⚡eric6166
⚡eric6166

Posted on • Updated on • Originally published at linkedin.com

Creating Custom Annotations for Validation in Spring Boot

Creating Custom Annotations for Validation in Spring Boot

1. Overview

While Spring standard annotations (@NotBlank, @NotNull, @Min, @Size, etc.) cover many use cases when validating user input, there are times when we need to create custom validation logic for a more specific type of input. In this article, I will demonstrate how to create custom annotations for validation.

2. Setup

We need to add the spring-boot-starter-validation dependency to our pom.xml file.


<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Enter fullscreen mode Exit fullscreen mode

3. Custom Field Level Validation

3.1 Creating the Annotation

Let’s create custom annotations to validate file attributes, such as file extension, file size, and MIME type.

  • ValidFileExtension

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(
        validatedBy = {FileExtensionValidator.class}
)
public @interface ValidFileExtension {
    String[] extensions() default {};

    String message() default "{constraints.ValidFileExtension.message}";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}
Enter fullscreen mode Exit fullscreen mode
  • ValidFileMaxSize

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(
        validatedBy = {FileMaxSizeValidator.class}
)
public @interface ValidFileMaxSize {
    long maxSize() default Long.MAX_VALUE; // MB

    String message() default "{constraints.ValidFileMaxSize.message}";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

Enter fullscreen mode Exit fullscreen mode
  • FileMimeTypeValidator

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(
        validatedBy = {FileMimeTypeValidator.class}
)
public @interface ValidFileMimeType {
    String[] mimeTypes() default {};

    String message() default "{constraints.ValidFileMimeType.message}";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}
Enter fullscreen mode Exit fullscreen mode

Let's break down these annotations' components:

  • @Constraint: Specifies the validator class responsible for the validation logic.
  • @Target({ElementType.FIELD}): Indicates that this annotation can only be applied to fields.
  • message(): The default error message if the validation fails.

3.2 Creating the Validator

  • FileExtensionValidator
public class FileExtensionValidator implements ConstraintValidator<ValidFileExtension, MultipartFile> {

    private List<String> extensions;

    @Override
    public void initialize(ValidFileExtension constraintAnnotation) {
        extensions = List.of(constraintAnnotation.extensions());
    }

    @Override
    public boolean isValid(MultipartFile file, ConstraintValidatorContext constraintValidatorContext) {
        if (file == null || file.isEmpty()) {
            return true;
        }
        var extension = FilenameUtils.getExtension(file.getOriginalFilename());
        return StringUtils.isNotBlank(extension) && extensions.contains(extension.toLowerCase());
    }
}
Enter fullscreen mode Exit fullscreen mode
  • FileMaxSizeValidator
public class FileMaxSizeValidator implements ConstraintValidator<ValidFileMaxSize, MultipartFile> {

    private long maxSizeInBytes;

    @Override
    public void initialize(ValidFileMaxSize constraintAnnotation) {
        maxSizeInBytes = constraintAnnotation.maxSize() * 1024 * 1024;
    }

    @Override
    public boolean isValid(MultipartFile file, ConstraintValidatorContext constraintValidatorContext) {
        return file == null || file.isEmpty() || file.getSize() <= maxSizeInBytes;
    }
}

Enter fullscreen mode Exit fullscreen mode
  • FileMimeTypeValidator

@RequiredArgsConstructor
public class FileMimeTypeValidator implements ConstraintValidator<ValidFileMimeType, MultipartFile> {

    private final Tika tika;
    private List<String> mimeTypes;

    @Override
    public void initialize(ValidFileMimeType constraintAnnotation) {
        mimeTypes = List.of(constraintAnnotation.mimeTypes());
    }

    @SneakyThrows
    @Override
    public boolean isValid(MultipartFile file, ConstraintValidatorContext constraintValidatorContext) {
        if (file == null || file.isEmpty()) {
            return true;
        }
        var detect = tika.detect(TikaInputStream.get(file.getInputStream()));
        return mimeTypes.contains(detect);
    }
}

Enter fullscreen mode Exit fullscreen mode

These classes are implementations of the ConstraintValidator interface and contain the actual validation logic.
For FileMimeTypeValidator, we will use Apache Tika (a toolkit designed to extract metadata and content from numerous types of documents).

3.3 Applying the Annotation

Let's create a TestUploadRequest class intended for handling file uploads, specifically for a PDF file.


@Data
public class TestUploadRequest {

    @NotNull
    @ValidFileMaxSize(maxSize = 10)
    @ValidFileExtension(extensions = {"pdf"})
    @ValidFileMimeType(mimeTypes = {"application/pdf"})
    private MultipartFile pdfFile;

}

Enter fullscreen mode Exit fullscreen mode

@RestController
@Validated
@RequestMapping("/test")
public class TestController {

    @PostMapping(value = "/upload", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE})
    public ResponseEntity<String> testUpload(@Valid @ModelAttribute TestUploadRequest request) {
        return ResponseEntity.ok("test upload");
    }
}

Enter fullscreen mode Exit fullscreen mode

4. Custom Class Level Validation

A custom validation annotation can also be defined at the class level to validate a combination of fields within a class.

4.1 Creating the Annotation

Let’s create @PasswordMatches annotation to ensure that two password fields match in a class.


@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(
        validatedBy = {PasswordMatchesValidator.class}
)
public @interface PasswordMatches {
    String message() default "{constraints.PasswordMatches.message}";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

Enter fullscreen mode Exit fullscreen mode
  • @Target({ElementType.TYPE}): Indicates that this annotation targets a type declaration.

4.2 Creating the Validator

  • PasswordDto
public interface PasswordDto {
    String getPassword();

    String getConfirmPassword();
}


Enter fullscreen mode Exit fullscreen mode
  • PasswordMatchesValidator
public class PasswordMatchesValidator implements ConstraintValidator<PasswordMatches, PasswordDto> {

    @Override
    public boolean isValid(PasswordDto password, ConstraintValidatorContext constraintValidatorContext) {
        return StringUtils.equals(password.getPassword(), password.getConfirmPassword());
    }
}

Enter fullscreen mode Exit fullscreen mode

The PasswordDto interface is an interface for objects that contain a password and a confirm password field.
The PasswordMatchesValidator class implements the ConstraintValidator interface and contains the logic for validating that the password and confirm password fields match.

4.3 Applying the Annotation

Let's create a RegisterAccountRequest class intended for handling user registration data.


@PasswordMatches
@Data
public class RegisterAccountRequest implements PasswordDto {

    @NotBlank
    private String username;

    @NotBlank
    @Email
    private String email;

    @NotBlank
    @ToString.Exclude
    private String password;

    @NotBlank
    @ToString.Exclude
    private String confirmPassword;
}

Enter fullscreen mode Exit fullscreen mode

@RestController
@Validated
@RequestMapping("/auth")
public class AuthController {

    @PostMapping("/register")
    public ResponseEntity<String> register(@RequestBody @Valid RegisterAccountRequest request) {
        return ResponseEntity.ok("register success");
    }
}

Enter fullscreen mode Exit fullscreen mode

5. Summary

In this short article, we discovered how easy it is to create custom annotations to verify a field or class. The code from this article is available over on my Github.

6. References

Top comments (2)

Collapse
 
aaiezza profile image
Alessandro Aiezza • Edited

Eric, really appreciate the explanation here. This Spring feature can be useful in many cases, and you've done a great job of showcasing here how it works and when you might use it.

I did have some thoughts that I thought might be interesting to share with our great community here on dev.to as it relates to this topic. In no way am I discounting the great work in this article, but rather wanted to share some of my own experience with the "Spring Annotation Magic" being leveraged here, and humbly/kindly suggest an alternative approach that others may find useful 🙂


While this can be a powerful feature of Spring, I have found alternatives to input validation that can provide a few additional benefits to what Spring provides through the use of annotations. Namely a solution that allows for more clarity to the developer how/where the code for validation exists for various inputs, and when it would be applied. Another benefit I find to alternatives of annotation driven coding patterns is the ability to leverage the vanilla language the code is written in. It can provide a lower learning curve, introduce fewer surprises/unknowns to developers, provide my robust validation (perhaps including the use of external systems to provide validation), and maybe even avoid security issues introduced by the maintainers of any external libraries.

Not saying this is the case for everyone; I have personally found it difficult at times to grok a Spring service that relies on beans that may or may not be leveraged at runtime based the presence of annotations. It tends to create a degree of elusiveness to the code flow, something that I find to create more obstacles to debugging and obtaining a general understanding of a system.

A potential alternative that others may find useful is founded on a principle of a more isolated, domain-driven system design. By isolating my business logic away from the frameworks that are needed to support the "accidental complexities" of how are system needs to communicate (In this case, through HTTP requests), I can more freely and more robustly model "reality" into code, and force other layers of my system to abide by the constraints if they "want in" on what the service layer has to offer.


I can use the Password validation as an example:

A String type is easily considered be the ideal Java "primitive"/standard-library underlying data structure that works best for storing a password. No doubt! However, the String type alone permits an enormous state space for its instances.

Eric Evans (author of Domain Driven Design) has some great thoughts on this that I would encourage the reader to go check out

Though I haven't found this phrase coined in any official capacity, I endearingly refer to my proposed alternative as "Domain Primitives". Others may simply refer to them as Wrapper Classes, which would be accurate as well, but my goal here is to create a more restrictive definition of types that one might use to intentionally:

  • Describe a basic domain concept in code
  • Creates an obvious place for validation of the data type
  • Creates a highly unit-testable class

Another benefit of a domain primitive, that I believe makes them slightly more obvious in practice, is the clarity brought to the rest of the code base performing operations with these data. (Particularly in languages like Java that does not support argument naming), we can easily prevent "misbehaving data types" from being passed into functions, and constructors of classes that compose the domain primitives.

Here is a potential example of Password in Kotlin and Java:

data class Password private constructor(val value: String) : CharSequence by value {
    constructor(firstEntry: String, secondEntry: String) : this(firstEntry) {
        require(firstEntry == secondEntry) { "Passwords must match" }
        require(!firstEntry.matches(Regex("^\\s+.+$|^.+\\s+$"))) { "Password cannot begin or end with whitespace" }
    }
}
Enter fullscreen mode Exit fullscreen mode
public class PasswordJava {
    private final String value;

    private PasswordJava(final String value) {
        this.value = value;
    }

    public PasswordJava(final String firstEntry, final String secondEntry) {
        this(firstEntry);
        if(!StringUtils.equals(firstEntry, secondEntry)) {
            throw new IllegalArgumentException("Passwords must match");
        }
        if(firstEntry.matches("^\\s+.+$|^.+\\s+$")) {
            throw new IllegalArgumentException("Password cannot begin or end with whitespace");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

All in all, classes are cheap to the system, and traditionally do a better job at conveying understanding when reading code, making it possible to do so more fluently.

Over the years, I continue to become a more enthusiastic advocate of "constructor-level validation".
I believe it to be an overlooked/underrated approach to software development in an Object-Oriented language. Perhaps due in part to a variety of libraries and frameworks out in the wild today, offering alternatives that tend to incentivize their further use and users deepening their reliance on those tools (Not accusing the owners of Spring of anything here 🙂, but sometimes less is more for a framework…)

The motto I tend to live by in OO is that "If it's not allowed, not valid, doesn't make sense, can't exist in the real-world application, then make it impossible to instantiate the instance of what it is meant to represent in code."

The code above provides an intuitive exception handling strategy that prevents the creation of a Password if it would not be able to exist in practice. What's nice about this too, is that if I have a created instance of the password elsewhere in code, I know it is legal and correct. It allows me to safely make that assumption everywhere now.

There is also a great Elm talk on this topic: "Make the impossible, impossible" that I highly recommend!
youtube.com/watch?v=IcgmSRJHu_8

Here is the remaining code that might be made to complete this picture:

interface PasswordDto {
    val password: String

    val confirmPassword: String
}

fun PasswordDto.toPassword() = Password(password, confirmPassword)
Enter fullscreen mode Exit fullscreen mode
class PasswordTest {
    @Test
    fun `should create password`() {
        assertDoesNotThrow {
            val pass = Password("pass1234", "pass1234")
            assertThat(pass.value).isEqualTo("pass1234")
        }
    }

    @TestFactory
    fun `should fail to create password with leading or trailing whitespace`() = listOf(
        "  pass1234",
        "pass1234 ",
        "\tpass1234",
        "pass1234\t",
        "\npass1234",
        "pass1234\n",
    )
        .map { input ->
            DynamicTest.dynamicTest("$input should fail") {
                assertFailure { Password(input, input) }.hasMessage("Password cannot begin or end with whitespace")
            }
        }
}
Enter fullscreen mode Exit fullscreen mode

If a password would be incorrect or invalid, don't allow an instance of password to be instantiated. The DTO object is one thing. You need an interface to effectively communicate externally. But isolate that from the business logic, and on the transition into the service layer, the conversion can be done.
In the PasswordDto class I made above, I actually created an extension function to more fluently convert to the domain object. Something that might be done from the presentation layer when it calls the service layer.

Thanks for reading! :)

Collapse
 
theelitegentleman profile image
Buhake Sindi

Nice article. Just to correct you a little bit, your tutorial is actually using the Jakarta EE Bean Validation framework, and you can write a standalone CDI application to actually test your custom annotation, without needing to run it through Spring.

That's the beauty of Spring framework: it adopts the Jakarta EE framework too.