Every developer has written an API that returns 500 Internal Server Error with nothing else. It passes code review, the happy path works, and it ships. Then a customer's integration fails at 11pm, and somebody has to work out what went wrong from a status code and a timestamp.

I've been on both ends of that. As a full stack Java developer I shipped 150+ REST APIs in Spring Boot. As a Technical Support Engineer at Salesforce, I now spend my days on the receiving end of failing integrations. The single cheapest fix for most of that pain is also the most boring one: consistent, specific error responses.

Think of it like this
A doctor who says "you're unwell" is technically correct and completely useless. A doctor who says "your iron is low, here's why, eat this, come back in two weeks" has done the job. Most APIs are the first doctor. This post shows how to make yours the second one.

What Spring Boot gives you by default

Out of the box, an unhandled exception in a Spring Boot REST controller returns a small JSON object with a timestamp, the status, a generic error name and the request path. The actual message is hidden by default (for good security reasons), and there is no stable way for a client to tell which kind of failure happened. A missing loan, an expired token and a database outage can all look almost identical to the caller.

Step 1: adopt ProblemDetail (RFC 9457)

Spring Framework 6 — the foundation of Spring Boot 3 and later — ships with first-class support for ProblemDetail, the standard error format defined in RFC 9457 (formerly RFC 7807). It gives every error the same shape: a type URI identifying the kind of error, a human-readable title, the HTTP status, a specific detail, and any extra properties you add.

For Spring MVC's own exceptions — bad JSON, unsupported media type, missing parameters — you can switch it on with a single property:

application.properties
# Render Spring MVC's built-in exceptions as application/problem+json spring.mvc.problemdetails.enabled=true

That property registers a handler only when you haven't written your own. In real projects you almost always will, so the next step matters more.

Step 2: one @RestControllerAdvice, one place for every error

Create domain-specific exceptions that carry the information a caller needs, then map them to ProblemDetail in a single @RestControllerAdvice. Extending ResponseEntityExceptionHandler means Spring's built-in exceptions are rendered as ProblemDetail too, so the property above becomes redundant.

LoanNotFoundException.java
public class LoanNotFoundException extends RuntimeException { private final String loanId; public LoanNotFoundException(String loanId) { super("Loan " + loanId + " does not exist or is not visible to this account"); this.loanId = loanId; } public String getLoanId() { return loanId; } }
ApiExceptionHandler.java
@RestControllerAdvice public class ApiExceptionHandler extends ResponseEntityExceptionHandler { private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class); @ExceptionHandler(LoanNotFoundException.class) public ProblemDetail handleLoanNotFound(LoanNotFoundException ex) { ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); pd.setTitle("Loan not found"); pd.setType(URI.create("https://api.example.com/errors/loan-not-found")); pd.setProperty("loanId", ex.getLoanId()); pd.setProperty("correlationId", MDC.get("correlationId")); return pd; } }

Returning a ProblemDetail from an @ExceptionHandler is enough — Spring sets the status code and the application/problem+json content type for you.

Step 3: make validation errors point at the exact field

Validation failures are where generic errors hurt most. A form with twelve fields that returns "Bad Request" forces the user — or the support engineer — to guess. Override the validation handler and return every field error in one response:

ApiExceptionHandler.java — validation
@Override protected ResponseEntity<Object> handleMethodArgumentNotValid( MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) { ProblemDetail pd = ex.getBody(); pd.setTitle("Validation failed"); pd.setDetail("One or more fields are invalid. See 'errors' for details."); Map<String, String> errors = new LinkedHashMap<>(); ex.getBindingResult().getFieldErrors() .forEach(fe -> errors.putIfAbsent(fe.getField(), fe.getDefaultMessage())); pd.setProperty("errors", errors); pd.setProperty("correlationId", MDC.get("correlationId")); return ResponseEntity.status(status).headers(headers).body(pd); }

The caller now receives something they can act on without opening a ticket:

HTTP 400 — application/problem+json
{ "type": "about:blank", "title": "Validation failed", "status": 400, "detail": "One or more fields are invalid. See 'errors' for details.", "errors": { "amount": "must be greater than 0", "tenureMonths": "must be between 6 and 84" }, "correlationId": "7f3c9a1e-4b2d-4c61-9d0e-2a8f5b7c1e34" }

Step 4: a safe catch-all for everything you didn't predict

Unexpected exceptions still need a response. The rule is simple: log everything, reveal nothing. The full stack trace goes to your logs, tagged with a correlation ID; the caller gets a polite message and that same ID to quote.

ApiExceptionHandler.java — catch-all
@ExceptionHandler(Exception.class) public ProblemDetail handleUnexpected(Exception ex) { String id = MDC.get("correlationId"); log.error("Unhandled exception [correlationId={}]", id, ex); ProblemDetail pd = ProblemDetail.forStatusAndDetail( HttpStatus.INTERNAL_SERVER_ERROR, "Something went wrong on our side. Quote reference " + id + " when contacting support."); pd.setTitle("Internal error"); pd.setProperty("correlationId", id); return pd; }

Where does correlationId come from? A small servlet filter that tags every request — I walk through it step by step in Correlation IDs and structured logging in Spring Boot.

The full stack half: using these errors in React

A consistent error shape pays off twice in a full stack application, because the frontend can handle every failure with one piece of code. Field errors go under their inputs; everything else becomes a banner that includes the reference ID.

LoanForm.jsx
async function submitLoan(form) { const res = await fetch("/api/loans", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form), }); if (!res.ok) { const problem = await res.json(); // same shape for every error if (problem.errors) { setFieldErrors(problem.errors); // show under each input } else { setBanner(`${problem.title}. Reference: ${problem.correlationId}`); } return; } navigate("/loans"); }
What support engineers wish every API did
Give each error class a stable type URI that clients can branch on, instead of parsing message strings. Keep the detail specific to this request. Return all validation errors at once, not one per round-trip. Always include a correlation ID. Keep server.error.include-stacktrace at its default of never. And document your error types in your OpenAPI spec — an error that's documented is an error that doesn't become a ticket.

Checklist before you ship your next endpoint

Every error response uses the same ProblemDetail shape, from one @RestControllerAdvice.
Domain exceptions carry the IDs and context a caller needs to fix the problem themselves.
Validation failures list every invalid field with a human-readable reason.
Unexpected errors are logged in full, revealed in part — never a stack trace to the client.
A correlation ID in every error links what the customer sees to what your logs recorded.