A REST API is easier to use when a failed request has a predictable response. If one error returns a short message, another returns a framework exception, and a third returns an empty body, clients must learn several formats just to handle failure.
Spring Boot ProblemDetail gives an API a standard shape for describing HTTP errors. This guide builds one response contract for invalid registration data, malformed JSON, missing resources, business conflicts and unexpected server errors. The main design choice is simple: use Spring MVC’s ProblemDetail support, add a small extension for field errors, and keep sensitive implementation details out of responses.
Scope: The examples target Java 17, Spring Boot 3.5.0 and the Spring MVC 6.2 API line. This is a specific baseline for the examples, a teaching baseline rather than a latest-release recommendation. The exception-handler method signature below is for Spring Framework 6.2. Check the matching Spring documentation and compile the examples against your selected dependencies before adopting them.
A failing request and the target contract
Suppose a registration endpoint expects an email address and an age. A request with a blank email and an age below the permitted minimum should receive HTTP 400. The client needs to know which fields need attention, while the server should avoid echoing sensitive or unnecessarily detailed input.
An illustrative response could look like this:
{
"type": "https://api.example.test/problems/validation",
"title": "Request validation failed",
"status": 400,
"detail": "One or more fields are invalid.",
"instance": "/registrations",
"errors": [
{
"pointer": "/email",
"code": "NotBlank",
"message": "Email is required."
},
{
"pointer": "/age",
"code": "Min",
"message": "Age must be at least 18."
}
]
}
This is illustrative output showing the proposed contract. It is not a captured response from a running application. The HTTP status and the JSON status must match. The field pointer helps a client associate an error with an input, while the code gives the client a more stable value to interpret than a sentence intended for people.
The response uses application/problem+json, the media type associated with problem details. RFC 9457 defines a machine-readable format for HTTP error responses and supersedes RFC 7807. Its standard members describe the problem; an API can add extension members for information that is useful to its clients. Read RFC 9457 for the specification.
Project and version scope
These examples use Maven and Java 17 with Spring Boot 3.5.0. The two dependencies relevant to the guide are Spring MVC and Bean Validation. Spring Boot’s validation starter brings in the validation integration and provider for a typical application. The test starter supplies Spring’s testing support, including MockMvc.
Create a pom.xml file in the project root:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.0</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>problem-detail-demo</artifactId>
<version>1.0.0</version>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Use jakarta.validation imports with this Boot 3 baseline. Do not substitute old javax.validation imports from older application examples. The examples also use the Framework 6.2 override signature for ResponseEntityExceptionHandler; do not copy that signature into a different major Framework line without checking its API.
For a broader introduction to controllers and request handling, see the site’s Spring MVC foundations and Spring controllers guides.
Problem fields and extension design
ProblemDetail represents the details of a problem response. Its standard members have distinct jobs:

| Member | Purpose in this API | Example |
|---|---|---|
type | A stable identifier for the kind of problem. Clients can use it to distinguish categories. | https://api.example.test/problems/validation |
title | A short, human-readable summary of the problem type. | Request validation failed |
status | The HTTP status represented by the response body. | 400 |
detail | A short explanation of this particular occurrence. | One or more fields are invalid. |
instance | A reference to this occurrence, commonly the request path. | /registrations |
Keep problem types and machine-readable error codes stable. Wording in detail or message can change as the API evolves, so a client should not depend on parsing a sentence to decide what to do. If errors are intended for a public API, document the available type identifiers and extension fields.
This guide adds an errors extension for field-level validation. Each entry contains a JSON Pointer-like pointer, a constraint code and a safe message. Do not put the rejected value into the extension: it could be a password, token, personal information or otherwise sensitive input. RFC 9457 supports extension members, and Spring can serialize properties added to a ProblemDetail as top-level JSON members. See the Spring MVC error-responses reference for Spring’s problem-detail support.
An optional correlation identifier can help an operations team find a corresponding server log entry. Treat it as a support aid, not as a secret or as a replacement for server-side logging. This example leaves it out to keep the response contract focused. If you add one, define where it comes from and ensure the same identifier is recorded safely in server logs.
Validation and malformed input
Bean Validation checks constraints on a Java object after Spring has read the request body into that object. For a controller parameter annotated with @Valid, a failed field constraint results in a MethodArgumentNotValidException. Malformed JSON is different: Jackson cannot turn the request text into the DTO, so field validation does not get a valid DTO to inspect.

Start with a DTO that uses Jakarta Validation constraints. This is a complete file named RegistrationRequest.java:
package com.example.errors;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
public record RegistrationRequest(
@NotBlank(message = "Email is required.")
@Email(message = "Email must be a valid address.")
String email,
@Min(value = 18, message = "Age must be at least 18.")
int age
) {
}
The example allows the default value of an omitted primitive int to be checked as zero, so a missing age fails the minimum constraint. If your API must distinguish “age omitted” from “age supplied as zero,” use a nullable wrapper type and an appropriate required-value constraint. That choice changes the field semantics and should be made deliberately.
Here is a complete controller file named RegistrationController.java. It also includes small in-memory storage so the successful request has a useful response without introducing a database:
package com.example.errors;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.http.ResponseEntity;
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("/registrations")
public class RegistrationController {
private final AtomicLong nextId = new AtomicLong(1);
private final Map<Long, RegistrationRequest> registrations =
new ConcurrentHashMap<>();
@PostMapping
public ResponseEntity<Map<String, Object>> register(
@Valid @RequestBody RegistrationRequest request) {
long id = nextId.getAndIncrement();
registrations.put(id, request);
return ResponseEntity
.created(URI.create("/registrations/" + id))
.body(Map.of("id", id, "email", request.email()));
}
}
The storage is only an example of a successful controller flow. It is not persistent, and it is not intended as a production registration service. A real application should use its existing service and persistence layers.
The constraint imports and validation integration are documented in the Spring MVC validation reference. Constraint messages are useful for a simple API, but they are not a guarantee of a particular localization strategy. For production, decide whether messages come from message bundles and whether clients should rely on codes rather than translated text.
Spring MVC’s ResponseEntityExceptionHandler provides handling for framework-raised MVC exceptions. Subclassing it lets the application customize the response for validation failures while retaining the framework’s handling paths for other supported exceptions, including unreadable request bodies. The class below is a complete file named ApiProblemHandler.java for the stated Spring Framework 6.2 baseline:
package com.example.errors;
import java.net.URI;
import java.util.List;
import java.util.Map;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
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;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ApiProblemHandler extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
List<Map<String, String>> errors =
ex.getBindingResult().getFieldErrors().stream()
.map(error -> Map.of(
"pointer", "/" + escapePointer(error.getField()),
"code", error.getCode() == null
? "Invalid"
: error.getCode(),
"message", safeMessage(error.getDefaultMessage())))
.toList();
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST,
"One or more fields are invalid.");
problem.setType(URI.create(
"https://api.example.test/problems/validation"));
problem.setTitle("Request validation failed");
problem.setInstance(URI.create(requestPath(request)));
problem.setProperty("errors", errors);
return handleExceptionInternal(
ex, problem, headers, HttpStatus.BAD_REQUEST, request);
}
private static String safeMessage(String message) {
return message == null || message.isBlank()
? "The value is invalid."
: message;
}
private static String escapePointer(String field) {
return field.replace("~", "~0").replace("/", "~1");
}
private static String requestPath(WebRequest request) {
String description = request.getDescription(false);
String prefix = "uri=";
return description.startsWith(prefix)
? description.substring(prefix.length())
: "/";
}
}
The errors list is added as a top-level extension property. Pointer escaping matters because JSON Pointer uses ~ and / specially. The sample uses the Java field path as a starting point. If the API has nested objects or a JSON naming strategy that changes field names, verify that the pointers match the JSON properties a client actually sends.
The handler deliberately returns HTTP 400 for this API’s validation failures. That is a common choice for invalid request content: the client needs to correct the submitted representation. The essential rule is that the status line and status member agree. Do not use a 200 response with an error object, and do not return 422 in the body while the HTTP response says 400.
For malformed JSON, the request fails while Spring is reading the body, before Bean Validation can report field constraints. The framework handles this through its unreadable-message exception path, rather than calling handleMethodArgumentNotValid. The subclass above does not override that separate method, so Spring’s base handler supplies its problem response. If you customize that path, keep its result within the same media type and status contract, and avoid returning parser details or request fragments to the client.
Method validation is another distinct path. Validation of a method’s parameters or return value can raise a different exception from DTO binding validation, depending on the controller declaration and the selected Spring version. Do not assume the field-error override shown here covers every validation scenario. Review the Spring MVC validation reference for the method-validation behavior that applies to your controller, then add a handler and tests for that path if your API uses it.
For more on boolean constraints and their intended meanings, see Boolean validation in Spring.
Domain and unexpected exceptions
Validation errors come from request input. Domain errors arise after the application has accepted the request shape but cannot complete the requested operation. A missing registration is a natural 404; trying to use an email already registered could be a 409 conflict.

The next complete file, RegistrationExceptions.java, defines two domain exceptions for the example:
package com.example.errors;
public final class RegistrationExceptions {
private RegistrationExceptions() {
}
public static class RegistrationNotFoundException
extends RuntimeException {
public RegistrationNotFoundException() {
super("Registration was not found.");
}
}
public static class RegistrationConflictException
extends RuntimeException {
public RegistrationConflictException() {
super("A registration already exists for this email.");
}
}
}
In a larger application, give each exception its own file if that better fits the project’s package and naming conventions. The controller can now raise these exceptions from a lookup or service operation. The following handlers can be added to ApiProblemHandler; they use the same response structure but do not expose stack traces or internal exception messages:
@ExceptionHandler(RegistrationExceptions.RegistrationNotFoundException.class)
public ResponseEntity<ProblemDetail> handleNotFound(
RegistrationExceptions.RegistrationNotFoundException ex,
HttpServletRequest request) {
return problem(
HttpStatus.NOT_FOUND,
"https://api.example.test/problems/not-found",
"Registration not found",
"The requested registration does not exist.",
request);
}
@ExceptionHandler(RegistrationExceptions.RegistrationConflictException.class)
public ResponseEntity<ProblemDetail> handleConflict(
RegistrationExceptions.RegistrationConflictException ex,
HttpServletRequest request) {
return problem(
HttpStatus.CONFLICT,
"https://api.example.test/problems/conflict",
"Registration conflict",
"A registration cannot be created in the current state.",
request);
}
These are excerpts, not a complete replacement file: add the methods to the advice class and include this import:
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Add this helper method to the same advice class. It creates the problem and returns a response whose HTTP status matches the status in the body:
private ResponseEntity<ProblemDetail> problem(
HttpStatus status,
String type,
String title,
String detail,
HttpServletRequest request) {
ProblemDetail body = ProblemDetail.forStatusAndDetail(status, detail);
body.setType(URI.create(type));
body.setTitle(title);
body.setInstance(URI.create(request.getRequestURI()));
return ResponseEntity.status(status).body(body);
}
Keep one custom advice subclass responsible for these MVC responses. Do not add a second blanket controller advice that handles the same framework exceptions, because competing handlers can make the final response depend on ordering or exception matching. The exception-handler methods above are intended to be part of the single ApiProblemHandler shown earlier.
An unexpected exception should not be sent back as its message. Messages may include database details, file paths or other internals. Add a last-resort safe response handler to the same advice class if the application needs to control that response:
private static final Logger LOG = LoggerFactory.getLogger(ApiProblemHandler.class);
@ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> handleUnexpected(
Exception ex,
HttpServletRequest request) {
LOG.error("Unexpected error handling request {}", request.getRequestURI(), ex);
return problem(
HttpStatus.INTERNAL_SERVER_ERROR,
"https://api.example.test/problems/internal-error",
"Internal server error",
"The request could not be completed.",
request);
}
The excerpt includes the logger declaration; add it alongside the methods in the same advice class. The Logger and LoggerFactory imports are listed above. The server log should be access-controlled and should avoid recording credentials or unnecessary request-body data. The client gets a stable, safe message, not a stack trace, SQL statement, password or rejected sensitive value.
A handler for Exception is intentionally broad. It is a last-resort response within the controller advice path, not a guarantee that every failure in the whole server reaches it. Some failures occur before request handling enters the DispatcherServlet, and infrastructure or container errors may use other paths. Do not promise that controller advice catches those cases.
Testing the contract
MockMvc can exercise a Spring MVC controller without starting a real HTTP server. The test below uses standalone setup with the controller and advice, and checks the validation contract, a successful registration, malformed JSON and the domain handlers. It is a complete test file, RegistrationControllerTest.java, assuming the methods and logger declaration described above are included in ApiProblemHandler.
package com.example.errors;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
class RegistrationControllerTest {
private MockMvc mockMvc;
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders
.standaloneSetup(new RegistrationController())
.setControllerAdvice(new ApiProblemHandler())
.build();
}
@Test
void invalidFieldsReturnProblemDetails() throws Exception {
mockMvc.perform(post("/registrations")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_PROBLEM_JSON)
.content("{\"email\":\"\",\"age\":16}"))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith(
MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.type").value(
"https://api.example.test/problems/validation"))
.andExpect(jsonPath("$.status").value(400))
.andExpect(jsonPath("$.errors").isArray())
.andExpect(jsonPath("$.errors[0].pointer").exists());
}
@Test
void validRegistrationReturnsCreated() throws Exception {
mockMvc.perform(post("/registrations")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content("{\"email\":\"reader@example.test\",\"age\":25}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").exists())
.andExpect(jsonPath("$.email").value("reader@example.test"));
}
@Test
void malformedJsonUsesFrameworkProblemHandling() throws Exception {
mockMvc.perform(post("/registrations")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_PROBLEM_JSON)
.content("{\"email\":"))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith(
MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.status").value(400));
}
@Test
void domainExceptionsUseTheirStatuses() throws Exception {
ApiProblemHandler handler = new ApiProblemHandler();
org.springframework.mock.web.MockHttpServletRequest request =
new org.springframework.mock.web.MockHttpServletRequest();
request.setRequestURI("/registrations/99");
org.springframework.http.ResponseEntity<org.springframework.http.ProblemDetail>
notFound = handler.handleNotFound(
new RegistrationExceptions.RegistrationNotFoundException(),
request);
org.springframework.http.ResponseEntity<org.springframework.http.ProblemDetail>
conflict = handler.handleConflict(
new RegistrationExceptions.RegistrationConflictException(),
request);
org.junit.jupiter.api.Assertions.assertEquals(404, notFound.getStatusCode().value());
org.junit.jupiter.api.Assertions.assertEquals(
404, notFound.getBody().getStatus());
org.junit.jupiter.api.Assertions.assertEquals(409, conflict.getStatusCode().value());
org.junit.jupiter.api.Assertions.assertEquals(
409, conflict.getBody().getStatus());
}
}
The final test calls the two domain handlers directly because the minimal registration controller does not include a lookup endpoint or a duplicate-email service. In a real application, prefer exercising those exceptions through the relevant controller request as well. That verifies the full route from service exception to HTTP response.
The test uses Accept: application/problem+json for errors and Accept: application/json for successful resource creation. Try other headers your API supports, such as Accept: application/json on an invalid request, and verify the actual negotiation behavior for the selected configuration. Clients do not all send the same Accept value. The API should make its supported representations clear and tests should cover them.
The sample test checks that the validation response has a problem media type, the expected status and an error pointer. For a complete contract test, also verify that a submitted password or other sensitive value never appears in the response. This sample DTO does not include a password, but the same check matters as soon as sensitive fields are added. Tests should check response bodies, not only status codes.
Run the tests with Maven using:
mvn test
The expected outcome is that Maven reports the tests as passing if the project files, dependencies and handler excerpts are assembled consistently. That is an expected outcome, not a claim that these examples have been run in a particular environment. If a compilation or assertion differs for your exact dependency set, consult the matching Spring API and adjust the code before describing it as verified.
Migration and troubleshooting
Spring MVC can produce RFC 9457 problem responses through ProblemDetail and ErrorResponse. Spring Boot can also enable built-in problem responses with spring.mvc.problemdetails.enabled. Since this guide chooses a custom advice subclass, do not also enable that property for the same setup. Pick one clear approach, then test the exception paths that matter to your API. The Spring MVC error-responses reference explains the framework support.
| Earlier pattern | Consistent ProblemDetail pattern | What to verify |
|---|---|---|
| One endpoint returns a string, another a map. | Return problem details for failures, with documented extension fields where needed. | Check status, media type, standard members and extension names. |
| Validation details are embedded in an arbitrary message. | Return an errors array with field pointers and codes. | Confirm the pointers match the request JSON and avoid rejected sensitive values. |
| Malformed JSON is treated as field validation. | Handle it as a request-body parsing failure, distinct from DTO constraint violations. | Send syntactically invalid JSON and check the framework exception path. |
| Every exception message is returned to the client. | Map known domain cases deliberately and use a safe generic 500 message for unexpected errors. | Check that logs retain useful diagnostics and responses do not expose internals. |
If your custom validation handler does not run, first check that the controller parameter uses @Valid and that the DTO uses jakarta.validation constraints. Then confirm the validation starter is present. A DTO with no failing constraints will not create a validation exception, and malformed JSON is handled through a different path.
If the response has a different body shape from your custom response, inspect which advice or Boot configuration handles the exception. A second broad advice class or enabling Boot’s built-in property alongside a custom subclass can make the response strategy unclear. Keep one deliberate MVC error-handling design and avoid overlapping catch-all handlers.
If the response status and JSON status disagree, correct the handler rather than asking clients to guess which value is authoritative. Build both from the same chosen status, as the custom domain helper does. Also check that a handler does not return an OK response around an error object.
If an override fails to compile, compare its arguments and visibility with the Framework version actually resolved by Maven. The signature in this guide is the Spring MVC 6.2 form used by this baseline. Framework major versions can differ, so do not fix a mismatch by changing imports at random. Confirm the selected Boot dependency management and use the matching API reference.
If an error response contains a stack trace, SQL, credentials, parser details or rejected sensitive input, remove it from the body. Log only what the application needs to investigate the issue, and take care not to log secrets there either. A problem response is for clients; server diagnostics belong in an appropriately managed logging system.
Reader checklist
Before adopting this contract, verify the actual application behavior—not just the shape of a code example:
- Confirm the project uses the intended Java, Spring Boot and Spring Framework versions, and that all imports and override signatures compile.
- Check that the validation dependency and Jakarta Validation annotations are active.
- Send a valid registration request and confirm the success status and response body.
- Send blank, missing and invalid fields; verify HTTP 400, matching JSON
statusand useful field pointers. - Send malformed JSON separately; confirm it follows the request-body parsing path and does not reveal parser internals.
- Exercise not-found and conflict cases through real application routes and verify HTTP 404 and 409 respectively.
- Force a safe unexpected failure in a controlled test environment; confirm the response is generic and the server logs contain no credentials or unnecessary sensitive data.
- Check the response for each supported
Acceptheader and confirm there is one clear advice strategy. - Verify clients treat stable problem types and error codes as identifiers rather than parsing human-readable messages.
Run the project’s Maven tests, add request-level cases for every important failure path, and inspect actual responses before calling the contract complete. That final check catches the differences between a well-formed example and the behavior your application truly exposes.
Dont SPAM