I am using Spring Boot and Jakarta Bean Validation in my project, and I have multiple validation rules for each field. My goal is to return only the first validation error for each field in the response, even when multiple validation constraints are violated for the same field.
For example, I have the following validation rules in my ContactRequest model:
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "contact_requests")
public class ContactRequest {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Name is required")
@Size(min = 2, max = 50, message = "Name must be between 2 and 50 characters")
private String name;
@NotBlank(message = "Email is required")
@Email(message = "Email should be valid")
private String email;
@NotBlank(message = "Message is required")
@Size(max = 500, message = "Message cannot exceed 500 characters")
private String message;
private LocalDateTime submittedAt;
// Getters and setters...
}
And the controller where I process the validation errors is as follows:
@PostMapping
public ResponseEntity<?> createRecord(@Valid @RequestBody ContactRequest contactRequest, BindingResult result) {
if (result.hasErrors()) {
Map<String, String> errors = new HashMap<>();
for (var error : result.getFieldErrors()) {
if (!errors.containsKey(error.getField())) {
errors.put(error.getField(), error.getDefaultMessage());
}
}
if (!errors.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of(
"status", false,
"message", "Validation failed",
"errors", errors));
}
}
// Proceed with the rest of the logic...
}
The Problem:
Sometimes, when I send an invalid request, I receive multiple error messages for a single field. For example:
If the name field is empty, I expect to see only the "Name is required" message, but I also get "Name must be between 2 and 50 characters" even though the field is empty.
What I've tried: I implemented the following logic in the controller to capture the first error for each field:
for (var error : result.getFieldErrors()) {
if (!errors.containsKey(error.getField())) {
errors.put(error.getField(), error.getDefaultMessage());
}
}
However, it doesn't always work as expected. In some cases, I still get multiple error messages for a field, even when the field is empty.
I want to ensure that only the first validation error (based on the order of the rules) is captured and returned for each field.
Can anyone suggest how to improve my approach to make sure only the first error is returned for each field, especially when there are multiple validation annotations on the same field?
OK, so the question seems to be how to achieve an ORDER of applying the validations. In this case, when iterating over the errors, they will always be in the same order, and getting the first will give deterministically one and the same error.
Each of the jakarta.validation
annotations accepts a list of groups
. They can be used to dictate when some validations to be applied and other not, but also to tell the sequence of the validations.
You need to declare validation groups as interfaces for both of your validations, let's say RequiredFirst
and SizeSecond
. These two interfaces must be grouped in another, let's say called, ValidationOrder
using the @GroupSequence
annotation.
Now you tell your fields which group to use. The @NotBlank(..., groups = RequiredFirst.class)
uses the required group and @Size
the other one.
When validating in your controller, use @Validated
instead of @Valid
so you can specify the ValidationOrder.class
to be applied.
The whole example would look like something like this:
package bg.codexio.test.api;
import jakarta.validation.GroupSequence;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/test")
public class TestController {
public interface NotBlankFirst {}
public interface SizeSecond {}
@GroupSequence({NotBlankFirst.class, SizeSecond.class})
public interface ValidationOrder {}
public record MyRequest(
@NotBlank(
message = "Name is required",
groups = NotBlankFirst.class
)
@Size(
min = 2,
max = 50,
message = "Name must be between 2 and 50 characters",
groups = SizeSecond.class
)
String name
) {}
@PostMapping
public String test(
@Validated(ValidationOrder.class) @RequestBody MyRequest request,
BindingResult result
) {
if (result.hasErrors()) {
var firstErrorForName = result.getFieldError("name")
.getDefaultMessage();
return "Error: " + firstErrorForName;
}
return request.name();
}
}