1. A single format for validation errors
When an API first appears, it is very easy to do this: “If it is a validation error, I will return something like {"errors":[...]}; if it is not found, I will return ProblemDetail.” On a small project, that even looks harmless. But as soon as a second endpoint and a second client type appear, the fun begins: client code turns into a pile of if/else branches based on the response format, and the documentation becomes contradictory. In the end, instead of one clear contract, we get two realities that have to be synchronized manually (which, as everyone knows, is a professional-sufferer-level skill).
The key idea here is this: validation is just one kind of client error, and it fits naturally into the common problem details structure. On top we still have the usual ProblemDetail with status=400 and our stable code=INVALID_INPUT, while the field-specific details are added in fieldErrors. Then the client does not need to guess which format the response has today: it always reads the top level first (code, title, status), and then optional details (fieldErrors, if present).
Put more simply, ProblemDetail is the “envelope”, and fieldErrors is the “sheet inside the envelope”. We do not print two different kinds of envelopes for different occasions. We just put different inserts inside while keeping one form.
2. The fieldErrors model: minimum fields
You can build fieldErrors in different ways: you can put everything there, all the way down to rejectedValue, the DTO class name, the list of all constraints, and a philosophical explanation of why life hurts. But in an API contract, minimalism is more useful: the less noise there is, the easier it is for the client to use the response, the more stable the contract is, and the lower the risk of leaking sensitive data (for example, if someone accidentally submits a password field, and we “politely” send it back in the error — it happens, yes).
A minimal field error usually answers three questions: which field, which rule was violated, and what to tell the user. That is enough for the frontend (highlight the field, show the message), for tests (verify the violation code), and for you (quickly understand what broke without entering “JSON shard archaeologist” mode).
Let us agree on this format for a fieldErrors item:
| Field | Example | Why it is needed |
|---|---|---|
| field | "title", "tags[1]", "metadata.description", "_global" | Error address in the request (path to the field) |
| code | "NotBlank", "Size", "Pattern" | Machine-readable violation code (usually the constraint name) |
| message | "title must not be blank" | Human-readable message |
And let us express this as a separate DTO in our project.
package com.example.tasktracker.api.dto.error;
// One item of the fieldErrors array in ProblemDetail.
// This DTO is part of the public contract, so we keep it minimal and stable.
public record FieldErrorResponse(
// Path to the problematic place in the request: field, nesting, collection index, or "_global"
String field,
// Machine-readable violation code (usually the constraint name)
String code,
// Message that can be shown to a user (or logged in the client)
String message
) {
}
Pay attention to the important “two-level” model: the top-level code (INVALID_INPUT) describes the whole problem as a whole, while the code inside fieldErrors describes local violations of specific fields. That is convenient for the client: first it understands the general scenario, then the details.
3. Path to a field: indices, nesting, cross-field
The path to a field is the part of the contract that is easiest to “accidentally break”, because it seems like: “Well, a field is a field.” In practice, the path is a bridge between your JSON contract and the client UI. If the path is inconvenient, unpredictable, or “sometimes with an index, sometimes without”, it becomes hard for the frontend to show errors correctly: for example, highlight a specific tag in a list or a specific nested object item.
In our course, we follow clear paths: for simple fields it is just the name (title), for collections it is name plus index (tags[1]), for nested objects it is a dot (metadata.description). Spring itself usually builds such paths in BindingResult, and that is a nice bonus: we do not need to invent our own addressing system from scratch.
There is one more nuance: errors at the “not one field, but a rule between fields” level. For example, “dueAfter must be earlier than dueBefore”. Such a violation does not have one single field, but the client still needs a clear point in the contract. In this course we fix one canon: for object-level and cross-field errors, we use field = "_global".
That means the problem applies to the whole request object, not to one specific property. This convention is simple for the client and does not force you to invent new synthetic names every time. That is exactly how class-level validators from the validation block end up in the same fieldErrors array, without creating a second error format.
4. Validation failures in Spring MVC: which exceptions to catch
At the level of “I wrote @Valid and it somehow works,” everything looks simple. But when we build a single error contract, we need to understand: which exceptions actually reach the global error handler, because that determines where we get the list of errors and how we extract the path to the field. The good news is that in a regular Spring MVC application, these scenarios are fairly repeatable.
Let us simplify the picture down to three typical sources of validation errors:
| Scenario | Controller example | What usually arrives | Where we get the errors from |
|---|---|---|---|
| Validation of a @RequestBody DTO | public ... create(@Valid @RequestBody TaskCreateRequest req) | MethodArgumentNotValidException | ex.getBindingResult() |
| Validation of a @ModelAttribute / criteria DTO | public ... list(@Valid TaskSearchCriteria criteria) | BindException | ex.getBindingResult() |
| Validation of method parameters (@RequestParam, @PathVariable) in controller method validation | public ... get(@Min(0) int page) | HandlerMethodValidationException | Parameter errors from the exception object |
Why do we like BindingResult so much? Because it gives us an almost ready-made list of FieldError, where we already have field, code, and defaultMessage. It is as if Spring itself prepared the ingredients for fieldErrors, and we just serve them neatly.
The most common and important case is MethodArgumentNotValidException for body DTOs. That is exactly what you will see on POST /api/v1/tasks, PUT, PATCH, POST comment, and so on.
For MVC controllers in our baseline, the main reference point is HandlerMethodValidationException. It lives separately from BindingResult, but on the outside it should still become the same INVALID_INPUT + fieldErrors. ConstraintViolationException is useful to know as a more general Jakarta Validation context, but not as the controller’s primary exception path.
5. GlobalExceptionHandler: BindingResult → fieldErrors
Now we move to the pleasant part: we take everything Spring has already collected and place it into our contract. It is important to do this in a way that handlers do not turn into copy-paste, and the response structure stays the same for any validation error.
This is also where the cross-field validation gap gets closed: one list will contain both ordinary field errors and object-level errors with field="_global".
Let us start with a small helper that turns BindingResult into a list of FieldErrorResponse. This will be our “translator from Spring language to API contract language.” And yes, this is a great place for a concise Stream — but only until it starts looking like a demon-summoning spell.
import com.example.tasktracker.api.dto.error.FieldErrorResponse;
import org.springframework.validation.BindingResult;
import java.util.List;
import java.util.stream.Stream;
private static final String GLOBAL_FIELD = "_global";
private List<FieldErrorResponse> toFieldErrors(BindingResult bindingResult) {
// Spring has already split the errors by field and built convenient paths like tags[1] or metadata.description
List<FieldErrorResponse> fieldErrors = bindingResult.getFieldErrors().stream()
.map(e -> new FieldErrorResponse(
e.getField(), // path to the field in the request
e.getCode(), // constraint name (usually NotBlank/Size/Pattern/...)
e.getDefaultMessage() // text for the user
))
.toList();
// Class-level / cross-field errors are also included in the same array.
// For them, we fix one canon in the contract: field = "_global".
List<FieldErrorResponse> globalErrors = bindingResult.getGlobalErrors().stream()
.map(e -> new FieldErrorResponse(
GLOBAL_FIELD,
e.getCode(),
e.getDefaultMessage()
))
.toList();
return Stream.concat(fieldErrors.stream(), globalErrors.stream())
.toList();
}
Now we need a way to create a “base” ProblemDetail for invalid input so that everywhere we have the same title, the same type, and the same code. This is a continuation of the idea from the previous step: consistency is better than creativity (even if you really want to be creative).
import com.example.tasktracker.api.dto.error.ApiErrorCode;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import java.net.URI;
private ProblemDetail invalidInputProblem(HttpStatusCode status, String instance) {
// This is not a separate error schema, just a fixed configuration
// of the common builder for the INVALID_INPUT scenario.
return appProblem(
status,
URI.create("/problems/invalid-input"),
"Invalid input",
"Request validation failed",
ApiErrorCode.INVALID_INPUT,
URI.create(instance)
);
}
The remaining question is where to get instance from (the request path). In ResponseEntityExceptionHandler handlers, we usually have access to WebRequest, and in the MVC world it can be cast to ServletWebRequest to get the HttpServletRequest. That sounds a little scarier than it really is.
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.context.request.WebRequest;
private String requestPath(WebRequest request) {
// In Spring MVC, WebRequest is usually a ServletWebRequest
ServletWebRequest servlet = (ServletWebRequest) request;
// For ProblemDetail.instance we need the request URI (without the domain)
return servlet.getRequest().getRequestURI();
}
And finally, we build a ProblemDetail for MethodArgumentNotValidException, adding fieldErrors as an extension field. Notice that we do not create a separate DTO and do not change the response shape — we simply add one more field to the same ProblemDetail.
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.context.request.WebRequest;
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request
) {
// 1) Create the base ProblemDetail for the INVALID_INPUT scenario
ProblemDetail pd = invalidInputProblem(status, requestPath(request));
// 2) Add the "insert into the envelope": the list of field errors
pd.setProperty("fieldErrors", toFieldErrors(ex.getBindingResult()));
// 3) Return the response in the standard ResponseEntityExceptionHandler format
return new ResponseEntity<>(pd, headers, status);
}
If you are thinking “that is all?” — yes, at the basic level, that is all. The nicest part here is that Spring has already done half the work: it validated, collected the errors, built the paths, and we just packaged them.
6. Query/criteria validation: the same INVALID_INPUT
It is very important that fieldErrors works not only for @RequestBody, but also for query/criteria scenarios. Otherwise the client has to keep in mind again: “if the error is in the body — one format, if the error is in the query — another.” And that is exactly what we are trying to get away from.
For a @ModelAttribute/criteria DTO, the typical case is BindException. It also has BindingResult, which means our helper toFieldErrors(...) is useful again without any changes. We simply make the same INVALID_INPUT response, only with a different error source.
import org.springframework.validation.BindException;
@Override
protected ResponseEntity<Object> handleBindException(
BindException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request
) {
// The logic is exactly the same: one and the same INVALID_INPUT scenario, just a different exception
ProblemDetail pd = invalidInputProblem(status, requestPath(request));
// BindException also has BindingResult, so we reuse the common helper
pd.setProperty("fieldErrors", toFieldErrors(ex.getBindingResult()));
return new ResponseEntity<>(pd, headers, status);
}
What about validation of parameters (page, size, taskId)? In an MVC controller in our baseline, the main path is HandlerMethodValidationException. Its API is a bit more verbose than BindingResult, but the meaning does not change: extract parameter errors and place them into the same INVALID_INPUT + fieldErrors.
So for the client, there is no difference where exactly the validation error came from — body DTO, criteria object, or method parameter constraints. The top-level code remains INVALID_INPUT, and then the client reads fieldErrors.
If, outside the controller pipeline, you separately encounter ConstraintViolationException, the conversion logic stays the same:
import com.example.tasktracker.api.dto.error.FieldErrorResponse;
import jakarta.validation.ConstraintViolationException;
import java.util.List;
private List<FieldErrorResponse> toFieldErrors(ConstraintViolationException ex) {
return ex.getConstraintViolations().stream()
// propertyPath usually contains the parameter name or a path inside the validated object
.map(v -> new FieldErrorResponse(
v.getPropertyPath().toString(),
v.getConstraintDescriptor().getAnnotation().annotationType().getSimpleName(),
v.getMessage()
))
.toList();
}
7. Example of the final JSON response
When we have made ProblemDetail + code + fieldErrors, the result becomes predictable and convenient. The client receives the same error structure every time, and the differences are only in the details. That is what a “single error contract” means: the same shape, different values.
Imagine that the client called POST /api/v1/tasks and sent invalid JSON:
{
"title": " ",
"tags": ["ok", "this-tag-is-way-too-long-for-our-contract-because-it-is-ridiculous"]
}
The response may look like this:
{
"type": "/problems/invalid-input",
"title": "Invalid input",
"status": 400,
"detail": "Request validation failed",
"instance": "/api/v1/tasks",
"code": "INVALID_INPUT",
"fieldErrors": [
{
"field": "title",
"code": "NotBlank",
"message": "title must not be blank"
},
{
"field": "tags[1]",
"code": "Size",
"message": "tag length must be between 1 and 30"
}
]
}
Why is this convenient for the client? Because now the frontend can do simple logic: if code == INVALID_INPUT, it takes fieldErrors, turns them into a map field -> message, and highlights the required fields. It does not need to parse the detail string, it does not need to guess where the error list is, and it does not need to distinguish “body validation” from “query validation” by the response format.
If a class-level validator triggers, the same array will contain an item with field = "_global". So a cross-field rule does not create a new error format either — it just lives in the same fieldErrors.
And one more pleasant bonus: your automated tests and manual checks become simpler. Verifying code=INVALID_INPUT and the presence of fieldErrors is much more stable than checking whether the word blank appears in detail.
8. Typical mistakes when working with fieldErrors
Mistake #1: making a second error format “just for validation”.
The most popular failure is to create a separate ValidationErrorResponse, return it for MethodArgumentNotValidException, and keep returning ProblemDetail for all other errors. At that moment the API contract is no longer single: the client needs two parsers, two documentation schemas, and two handler branches. It is better to keep one ProblemDetail structure and add fieldErrors as an extension field.
Mistake #2: returning only the string “validation failed”.
Sometimes it seems that detail="Validation failed" is enough, because “the client is a human, they will understand.” But the client is also code that must highlight a specific field. Without fieldErrors, the frontend is forced either to show a general error or to guess what exactly went wrong. Even if the UI is primitive right now, the contract is better designed to be automatically useful.
Mistake #3: losing indices and nesting in paths.
If you write only "tags" in fieldErrors instead of "tags[1]", the client will not be able to highlight the specific problematic list item. The same applies to nested objects: "metadata" instead of "metadata.description" turns an exact error into “something is wrong somewhere over there.” It is better to keep the path as precise as possible, exactly as BindingResult returns it.
Mistake #4: mixing the top-level error code with the code field inside fieldErrors.
Sometimes developers put NotBlank into the top-level code, and INVALID_INPUT into fieldErrors. That produces an upside-down world: the general error is described by the local rule of one field. It is more convenient to keep the top-level code as the scenario identifier (INVALID_INPUT), and inside fieldErrors keep the causes for specific fields.
Mistake #5: stuffing too much “diagnostics” into fieldErrors.
It is very tempting to add rejectedValue, the DTO class name, a stack trace, and “just a little more and it will be a log.” But an error response is a public contract. It must be compact and safe. If you need diagnostics, they belong in logs and monitoring, and the API response only needs the error address, the rule code, and the message.
GO TO FULL VERSION