name,email,phone number, etc, there must be some form of validation logic implemented to ensure those fields follow certain requirements. This can result in a longer and complicated validation logic if the number of fields received from the client increases. This is where Spring Boot Validation can come in useful.
Built-in Constraint definitions are the annotations available for validations.min: Minimum size (default 0)max: Maximum size (default Integer.MAX_VALUE@Size(min = 2, max = 10)
private String userName;
//The userName must be between 2 and 10 characters
@NotNull ensures that a field is not null@NotNull
private String email;
//The email field must not be null
@NotEmpty ensures that a field is not null and is not empty (size > 0 for collections or length > 0 for strings)
NotBlank ensures that a string is not null, empty or only contains whitespace
regexp: The regex patternmessage: Custom error message@Pattern(regexp = "^[a-zA-Z0-9]{6,}$", message = "Password must be at least 6 characters long and alphanumeric")
private String password;
password must have at least 6 alphanumeric characters.@Max validates that a number is less than or equal to the specified maximum.@Max(100)
private int age;
//The age must be <= 100
@Min validates that a number is greater than or equal to the specified minimum.@min(18)
private int age;
//The age must be >= 18
@AssertTrue validates that a boolean field is true.@AssertTrue
private boolean isActive;
//The isActive field must be true
AssertFalse is opposite of @AssertTrue@Valid
private Address address;
// Validates the fields of the Address object using its own annotations
@Past validates that a java.util.Date or java.time object represents a date/time in the past.@PastOrPresent validates that a date/time is in the past or present.@Future validates a date/time is in the future@FutureOrPresent validates that a date/time is in the future or present.@Past
private LocalDate birthDate;
// The birthDate must be before the current date
@PastOrPresent
private LocalDate registrationDate;
//registrationDate cannot be in the future
@Future
private LocalDate expiryDate;
//expiryDate must be after the current date
@FutureOrPresent
private LocalDate appointmentDate;
//appointmentDate cannot be in the past


UserRegisterRequest.java:@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class UserRegisterRequest {
@NotBlank
private String name;
@NotBlank
@Size(min = 1, max = 12)
private String password;
@NotNull //since Integer
@Min(1)
@Max(100)
private Integer age;
@Email
private String email;
@Pattern(regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$")
private String phoneNumber;
@FutureOrPresent
private LocalDateTime registerAt;
}
UserApiController.java@RestController
@RequestMapping("/api/user")
@Slf4j
public class UserApiController {
@PostMapping("")
public UserRegisterRequest regiester(
@Valid
@RequestBody
UserRegisterRequest userRegisterRequest
){
log.info("init: {}", userRegisterRequest);
return userRegisterRequest;
}
}
POST request to http://localhost:8080/api/user with the following json body:{
"name": "",
"password": "",
"age": 20,
"email": "",
"phone_number": "",
"register_at": "2024-11-18T13:06:00"
}

{
"name": "Choi Seunghwan",
"password": "password",
"age": 20,
"email": "gdis@fds.com",
"phone_number": "010-3829-3923",
"register_at": "2024-12-18T13:06:00"
}
Api.java which acts as a blueprint for all API requests.Api.java:@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Api<T> {
private String resultCode;
private T data;
private String resultMessage;
private Error error;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class Error{
private List<String> errorMessage;
}
}
UserApiController.java to wrap the UserRegisterRequest with Api:@RestController
@RequestMapping("/api/user")
@Slf4j
public class UserApiController {
@PostMapping("")
public Api<UserRegisterRequest> regiester(
@Valid
@RequestBody
Api<UserRegisterRequest> userRegisterRequest
){
log.info("init: {}", userRegisterRequest);
return userRegisterRequest;
}
}
Api.java:{
"result_code": "",
"result_message": "",
"data": {
"name": "Choi Seunghwan",
"password": "password1111111111111",
"age": 20,
"email": "gdis@fds.com",
"phone_number": "010-3829-3923",
"register_at": "2024-12-18T13:06:00"
},
"error": {
"error_message": [
]
}
}
UserRegisterRequest.java. This is because within the Api.java, the UserRegisterRequest is saved within private T data. If we do not annotate the private T data with @Valid, it does not run all the validations specified within the UserRegisterRequest. Once we modify the Api.java as below:public class Api<T> {
private String resultCode;
//Added @Valid annotation
@Valid
private T data;
private String resultMessage;
private Error error;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class Error{
private List<String> errorMessage;
}
}
UserRegisterRequest.java were performed, rejecting the password with length greater than 12 as specified. [Field error in object 'api' on field 'data.password': rejected value [password1111111111111]; codes [Size.api.data.password,Size.data.password,Size.password,Size.java.lang.String,Size]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [api.data.password,data.password]; arguments []; default message [data.password],12,1]; default message [크기가 1에서 12 사이여야 합니다]]
BindingResult works:@NotNull, @Size), Spring performs validations.BindingResult object with those errors. @RestController
public class UserController {
@PostMapping("/register")
public String registerUser(
@Valid
@RequestBody
UserDto userDto, //Bind and validate the request body
BindingResult bindingResult //To capture validation errors
){
//check for errors
if (bindingResult.hasErrors()){
//Handle validation errors
}
//Proceed with normal logic
}
}
BindingResult must immediately follow the @Validated or @Valid object in the method signature.BindingResult is not placed directly after the validated object, Spring will throw an IllegalStateException.hasErrors()true if there are any validation or binding errors.if (bindingResult.hasErrors()){
//Handle errors
}getAllErrors()List<ObjectError> errors = bindingResult.getAllErrors();getFieldErrors()List<FieldError> fieldErrors = bindingResult.getFieldErrors();FieldError fieldError = bindingResult.getFieldError("email");getFieldValue(String field)Object rejectedValue = bindingResult.getFieldValue("username");@RestController
@RequestMapping("/api/user")
@Slf4j
public class UserApiController {
@PostMapping("")
public Api<? extends Object> regiester(
@Valid
@RequestBody
Api<UserRegisterRequest> userRegisterRequest,
BindingResult bindingResult
){
log.info("init: {}", userRegisterRequest);
if(bindingResult.hasErrors()){
//Collect error messages using a stream to process all FieldError objects from bindingResult
var errorMessageList = bindingResult.getFieldErrors().stream()
.map(it -> {
var format = "%s : { %s } is %s";
var message = String.format(format, it.getField(), it.getRejectedValue());
return message;
}).collect(Collectors.toList());
//Create an error object, encapsulating the list of error messages
var error = Api.Error
.builder()
.errorMessage(errorMessageList)
.build();
//Create an error response
var errorResponse = Api
.builder()
.resultCode(String.valueOf(HttpStatus.BAD_REQUEST.value()))
.resultMessage(HttpStatus.BAD_REQUEST.getReasonPhrase())
.error(error)
.build();
return errorResponse;
}
//Handle valid requests
Api<UserRegisterRequest> response = Api.<UserRegisterRequest>builder()
.resultCode(String.valueOf(HttpStatus.OK.value()))
.resultMessage(HttpStatus.OK.getReasonPhrase())
.data(userRegisterRequest.getData())
.build();
return response;
}
}
Api<? extends Object>: The method returns a generic Api object, where the actual type of data can vary. This supports flexible response types. {
"result_code": "",
"result_message": "",
"data": {
"name": "",
"password": "password",
"age": 20,
"email": "gdis@fds.com",
"phone_number": "010-43829-3923",
"register_at": "2024-12-18T13:06:00"
},
"error": {
"error_message": [
]
}
}
{
"result_code": "400",
"data": null,
"result_message": "Bad Request",
"error": {
"error_message": [
"data.name : { } 은 공백일 수 없습니다",
"data.phoneNumber : { 010-43829-3923 } 은 \"^\\d{2,3}-\\d{3,4}-\\d{4}$\"와 일치해야 합니다"
]
}
}
MethodArgumentNotValidException:@Valid. If any validation fails, Spring throws a MethodArgumentNotValidException. MethodArgumentNotValidException, significantly de-cluttering our controller logic. ValidationExceptionHandler under package exception:@RestControllerAdvice
@Slf4j
public class ValidationExceptionHandler {
@ExceptionHandler(value = {MethodArgumentNotValidException.class})
public ResponseEntity<Api> valdiationException(
MethodArgumentNotValidException exception
) {
log.error("", exception);
var errorMessageList = exception.getFieldErrors().stream()
.map(it -> {
var format = "%s : { %s } 은 %s";
var message = String.format(format, it.getField(), it.getRejectedValue(), it.getDefaultMessage());
return message;
}).collect(Collectors.toList());
var error = Api.Error
.builder()
.errorMessage(errorMessageList)
.build();
var errorResponse = Api
.builder()
.resultCode(String.valueOf(HttpStatus.BAD_REQUEST.value()))
.resultMessage(HttpStatus.BAD_REQUEST.getReasonPhrase())
.error(error)
.build();
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(errorResponse);
}
}
ValidationExceptionHandler:{
"result_code": "400",
"data": null,
"result_message": "Bad Request",
"error": {
"error_message": [
"data.name : { } 은 공백일 수 없습니다",
"data.phoneNumber : { 010-43829-3923 } 은 \"^\\d{2,3}-\\d{3,4}-\\d{4}$\"와 일치해야 합니다"
]
}
}
UserRegisterRequest.java,public class UserRegisterRequest {
//other fields...
@NotBlank
private String name;
private String nickName;
@AssertTrue(message = "name or nickname must exist)
public boolean isNameCheck(){
if(Objects.nonNull(name) && !name.isBlank()){
return true;
}
if(Objects.nonNull(nickName) && !nickName.isBlank()){
return true;
}
return false;
}
}
{
"result_code": "400",
"data": null,
"result_message": "Bad Request",
"error": {
"error_message": [
"data.nameCheck : { false } 은 name or nickname must exist",
"data.name : { } 은 공백일 수 없습니다"
]
}
}
ConstraintValidator allows for creation of reusable, custom validation rules beyond the default annotations provided.@Constraint: Links the annotation to a specific validator class@Target: Specifies where the annotation can be applied (e.g. fields, methods)@Retention: Indicates how long the annotation should be retained (typically at runtime)@Constraint(validatedBy = CustomValidator.class) //Specifies the validator class
@Target({ElementType.FIELD, ElementType.METHOD}) //Where the annotation can be applied
@Retention(RetentionPolicy.RUNTIME) //Retained at runtime for reflection
public @interface CustomValidation {
String message() default "Invalid value"; //Default error message
Class<?> groups() default {}; //For grouping constraints (optional)
Class<? extends Payload>[] payload() default {}; //Metadata for additional processing (optional)
}
ConstraintValidator interface contains two methods:initialize: Optional initialization logicisValid: Implements the validation logicpublic class CustomValidator implements ConstraintValidator<CustomValidation, String>{
@Override
public void initialize(CustomValidation constraintAnnotation){
//Initialization logic (if any)
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context){
//Validation logic
if (value == null || value.trim().isEmpty()){
return false; //Fails validation
}
return value.matches("^[a-zA-Z]+$"); // Passes if only letters
}
}
annotation/PhoneNumber:@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {PhoneNumberValidator.class})
public @interface PhoneNumber {
String message() default "Phone number invalid format. e.g.) 000-0000-0000";
String regex() default "^\\d{2,3}-\\d{3,4}-\\d{4}$";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
validator/PhoneNumberValidator:public class PhoneNumberValidator implements ConstraintValidator<PhoneNumber, String> {
private String regexp;
@Override
public void initialize(PhoneNumber constraintAnnotation) {
this.regexp = constraintAnnotation.regex();
}
@Override
public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
boolean result = Pattern.matches(s, regexp);
return result;
}
}
UserRegisterRequest:public class UserRegisterRequest {
@PhoneNumber
private String phoneNumber;
//other fields
}
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {YearMonthValidator.class})
@NotBlank
public @interface YearMonth {
String message() default "Invalid Year Month format. e.g.) 20241124";
String pattern() default "yyyyMMdd";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}