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>
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 {};
}
-
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 {};
}
-
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 {};
}
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());
}
}
-
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;
}
}
-
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);
}
}
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;
}
@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");
}
}
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 {};
}
-
@Target({ElementType.TYPE})
: Indicates that this annotation targets a type declaration.
4.2 Creating the Validator
-
PasswordDto
public interface PasswordDto {
String getPassword();
String getConfirmPassword();
}
-
PasswordMatchesValidator
public class PasswordMatchesValidator implements ConstraintValidator<PasswordMatches, PasswordDto> {
@Override
public boolean isValid(PasswordDto password, ConstraintValidatorContext constraintValidatorContext) {
return StringUtils.equals(password.getPassword(), password.getConfirmPassword());
}
}
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;
}
@RestController
@Validated
@RequestMapping("/auth")
public class AuthController {
@PostMapping("/register")
public ResponseEntity<String> register(@RequestBody @Valid RegisterAccountRequest request) {
return ResponseEntity.ok("register success");
}
}
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
- Baeldung. (n.d.). Spring MVC Custom Validator. Retrieved from https://www.baeldung.com/spring-mvc-custom-validator
Top comments (2)
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, theString
type alone permits an enormous state space for its instances.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:
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: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.Here is the remaining code that might be made to complete this picture:
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! :)
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.