How to buildOctober 12, 20267 min read
JavaStep 6 of 6

Recipe: a unified error format in Spring Boot

In short

A unified error format in Spring Boot takes one class: a @RestControllerAdvice that extends ResponseEntityExceptionHandler returns an RFC 9457 ProblemDetail for every case — validation errors, 404, 409 and unexpected 500s. The client always gets application/problem+json with a clear detail, while the stack trace stays in the log and is linked to the response through an errorId.

Code recipeLevel: IntermediateJava / Spring RecipesPractice: ~20–40 min

After this you will be able to

  • plug in a GlobalExceptionHandler that returns every API error as application/problem+json
  • return request body and parameter errors as an errors list that says where each error is
  • answer unexpected failures with a 500 without a stack trace and find them in the log by errorId
  • check the response format with MockMvc tests in a @WebMvcTest slice

Verified with

Spring Boot 4.1.1Spring Framework 7.0.9Java 25 LTS (Eclipse Temurin 25.0.4)Hibernate Validator 9.1.3Maven 3.10.0

Checked against docs: October 12, 2026

Contents
  1. 01The problem: API errors in one format
  2. 02Ready code: exceptions and GlobalExceptionHandler
  3. 03How to plug it in
  4. 04How to check: MockMvc tests and responses
  5. 05What not to send to the client, and limitations
  6. 06Links and next step

The problem: API errors in one format

Without a shared handler every error looks different. An exception from a service becomes a 500 with Spring Boot's default body, a validation error becomes a 400 with its own fields, and a try/catch in a controller returns a plain string. The frontend and integrations have to parse several formats, and a stray ex.getMessage() in a response exposes a database address or a piece of SQL.

This recipe fixes it with one class. Every error response gets the application/problem+json type and the fields of RFC 9457 "Problem Details for HTTP APIs": type, title, status, detail, instance. In Spring Framework the standard is represented by the ProblemDetail class, and the ResponseEntityExceptionHandler base class already turns built-in Spring MVC errors into it. What each field means and when to define your own type is covered in the API design lesson.

Invalid request body fields

Exception
MethodArgumentNotValidException
Response
400, type …/validation-error, an errors list with pointer

A parameter constraint is violated (@Max on limit)

Exception
HandlerMethodValidationException
Response
400, same type, the parameter name in errors

No such record

Exception
NotFoundException (ours)
Response
404, no type field, which means about:blank

Conflict with existing data: SKU taken

Exception
ConflictException (ours)
Response
409, our own type …/duplicate-sku

Malformed JSON, wrong method, unknown URL

Exception
built-in Spring MVC exceptions
Response
400, 405, 404 — handled by the base class

Anything else

Exception
any Exception
Response
500, a generic text and an errorId; the stack trace goes to the log only

Ready code: exceptions and GlobalExceptionHandler

First, two domain exceptions. The service throws them without knowing anything about HTTP: business logic does not depend on the web layer, and the status code is chosen in one place.

src/main/java/com/example/shop/error/NotFoundException.java
package com.example.shop.error;

// the resource does not exist: the handler turns this exception into a 404
public class NotFoundException extends RuntimeException {

    public NotFoundException(String message) {
        super(message);
    }
}
src/main/java/com/example/shop/error/ConflictException.java
package com.example.shop.error;

// the request conflicts with the current state of the data: 409
// type is a short problem-type name; the documentation URI is built from it
public class ConflictException extends RuntimeException {

    private final String type;
    private final String title;

    public ConflictException(String type, String title, String detail) {
        super(detail);
        this.type = type;
        this.title = title;
    }

    public String getType() {
        return type;
    }

    public String getTitle() {
        return title;
    }
}

Now the handler itself. It belongs in a package inside the application package: then @SpringBootApplication finds the class through component scanning and applies it to every controller.

src/main/java/com/example/shop/error/GlobalExceptionHandler.java
package com.example.shop.error;

import jakarta.servlet.http.HttpServletRequest;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.MessageSourceResolvable;
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.validation.FieldError;
import org.springframework.validation.method.ParameterValidationResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

// All API errors use the RFC 9457 format (application/problem+json).
// The base class already handles built-in Spring MVC errors:
// malformed JSON — 400, wrong method — 405, unknown URL — 404, and so on
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    // documentation for the problem types should live at this address
    static final String PROBLEMS = "https://api.example.com/problems/";

    @ExceptionHandler(NotFoundException.class)
    public ProblemDetail handleNotFound(NotFoundException ex) {
        // no type set: per the RFC that means about:blank, and title becomes "Not Found"
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler(ConflictException.class)
    public ProblemDetail handleConflict(ConflictException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
        problem.setType(URI.create(PROBLEMS + ex.getType()));
        problem.setTitle(ex.getTitle());
        return problem;
    }

    // Anything unexpected is a 500 without details. The stack trace goes only to the log,
    // and the client gets an errorId that leads straight to that log entry
    @ExceptionHandler(Exception.class)
    public ProblemDetail handleUnexpected(Exception ex, HttpServletRequest request) {
        String errorId = UUID.randomUUID().toString();
        log.error("Unhandled error {} in {} {}", errorId, request.getMethod(), request.getRequestURI(), ex);
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
                "Internal server error. Give support this code: " + errorId);
        problem.setProperty("errorId", errorId);
        return problem;
    }

    // @Valid @RequestBody: field errors in the request body
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
        List<Map<String, String>> errors = new ArrayList<>();
        for (FieldError error : ex.getBindingResult().getFieldErrors()) {
            errors.add(Map.of("pointer", toPointer(error.getField()), "detail", error.getDefaultMessage()));
        }
        return handleExceptionInternal(ex, validationProblem(status, errors), headers, status, request);
    }

    // constraints directly on method parameters: @RequestParam @Max(100) int limit
    @Override
    protected ResponseEntity<Object> handleHandlerMethodValidationException(
            HandlerMethodValidationException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
        List<Map<String, String>> errors = new ArrayList<>();
        for (ParameterValidationResult result : ex.getParameterValidationResults()) {
            String name = result.getMethodParameter().getParameterName();
            for (MessageSourceResolvable error : result.getResolvableErrors()) {
                errors.add(Map.of("parameter", name, "detail", error.getDefaultMessage()));
            }
        }
        return handleExceptionInternal(ex, validationProblem(status, errors), headers, status, request);
    }

    private static ProblemDetail validationProblem(HttpStatusCode status, List<Map<String, String>> errors) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, "Check the request parameters");
        problem.setType(URI.create(PROBLEMS + "validation-error"));
        problem.setTitle("Validation failed");
        problem.setProperty("errors", errors);
        return problem;
    }

    // items[0].name -> #/items/0/name (JSON Pointer, as in the RFC 9457 example)
    private static String toPointer(String field) {
        return "#/" + field.replace("[", "/").replace("]", "").replace('.', '/');
    }
}
  • @RestControllerAdvice is @ControllerAdvice plus @ResponseBody: its handler methods apply to all controllers and write their result to the response body.
  • Extending ResponseEntityExceptionHandler gives RFC 9457 handling for every built-in Spring MVC exception. handleMethodArgumentNotValid and handleHandlerMethodValidationException are overridden to add the errors list.
  • Validation errors arrive as two exceptions: MethodArgumentNotValidException for an object with @Valid, and HandlerMethodValidationException when constraints sit directly on method parameters. The Spring documentation recommends handling both.
  • The Exception.class handler does not take over built-in errors: among several matching methods Spring picks the one whose exception is closest to the thrown one in the hierarchy. So malformed JSON still gets a 400 from the base class, not a 500.
  • Spring fills in instance with the request path, and it does not write type when it is about:blank: under RFC 9457 a missing field means exactly about:blank.
  • errorId and errors are extension members. The RFC allows custom fields, and clients must ignore the ones they do not recognise.

How to plug it in

  1. Dependencies

    You need spring-boot-starter-webmvc and spring-boot-starter-validation, plus spring-boot-starter-webmvc-test for tests. A start.spring.io project with the web and validation dependencies already has them.

  2. Copy three classes

    NotFoundException, ConflictException and GlobalExceptionHandler go into an error package. Replace https://api.example.com/problems/ with the address where your error types will be documented: RFC 9457 recommends that a type URI lead to a human-readable description.

  3. Throw exceptions from the service

    Instead of return null or ResponseEntity.notFound() in the controller, the service throws NotFoundException or ConflictException. The controller stays free of try/catch.

  4. Rewrite built-in error texts

    By default Spring answers an unknown URL with the detail "No static resource api/unknown." — an unnecessary hint about the internals. Texts are set in messages.properties under the key problemDetail. plus the fully qualified exception class name.

  5. No need for the problemdetails property

    spring.mvc.problemdetails.enabled=true turns on Spring Boot's built-in handler only when there is no ResponseEntityExceptionHandler bean of your own. With the class from this recipe you do not need the property.

pom.xml
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.1</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>
<!-- … -->
<properties>
    <java.version>25</java.version>
</properties>
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
pom.xml fragment of a start.spring.io project (web + validation)

The service and controller the recipe was tested on: in-memory storage, a unique SKU and a limit constraint placed directly on the method parameter.

src/main/java/com/example/shop/product/Product.java
package com.example.shop.product;

import java.math.BigDecimal;

public record Product(long id, String name, String sku, BigDecimal price) {
}
src/main/java/com/example/shop/product/ProductRequest.java
package com.example.shop.product;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;

public record ProductRequest(
        @NotBlank(message = "Name is required")
        String name,

        @NotBlank(message = "SKU is required")
        @Pattern(regexp = "[A-Z0-9-]{3,20}", message = "SKU must be 3 to 20 characters: A–Z, 0–9 and hyphen")
        String sku,

        @NotNull(message = "Price is required")
        @Positive(message = "Price must be greater than zero")
        BigDecimal price) {
}
src/main/java/com/example/shop/product/ProductService.java
package com.example.shop.product;

import com.example.shop.error.ConflictException;
import com.example.shop.error.NotFoundException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Service;

// in-memory storage, so the recipe does not depend on a database
@Service
public class ProductService {

    private final Map<Long, Product> products = new ConcurrentHashMap<>();
    private final Map<String, Long> idsBySku = new ConcurrentHashMap<>();
    private final AtomicLong sequence = new AtomicLong();

    public List<Product> list(int limit) {
        return products.values().stream().limit(limit).toList();
    }

    public Product get(long id) {
        Product product = products.get(id);
        if (product == null) {
            throw new NotFoundException("Product " + id + " not found");
        }
        return product;
    }

    public Product create(ProductRequest request) {
        long id = sequence.incrementAndGet();
        if (idsBySku.putIfAbsent(request.sku(), id) != null) {
            throw new ConflictException("duplicate-sku", "SKU already taken",
                    "A product with SKU " + request.sku() + " already exists");
        }
        Product product = new Product(id, request.name().strip(), request.sku(), request.price());
        products.put(id, product);
        return product;
    }
}
src/main/java/com/example/shop/product/ProductController.java
package com.example.shop.product;

import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

// no try/catch in the controller: GlobalExceptionHandler deals with errors
@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductService service;

    public ProductController(ProductService service) {
        this.service = service;
    }

    @GetMapping
    public List<Product> list(
            @RequestParam(defaultValue = "20")
            @Min(value = 1, message = "Must be at least 1")
            @Max(value = 100, message = "Must be at most 100") int limit) {
        return service.list(limit);
    }

    @GetMapping("/{id}")
    public Product get(@PathVariable long id) {
        return service.get(id);
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Product create(@Valid @RequestBody ProductRequest request) {
        return service.create(request);
    }
}
src/main/resources/messages.properties
# texts for built-in Spring MVC errors: do not reveal internals
problemDetail.org.springframework.web.servlet.resource.NoResourceFoundException=No such URL in this API
problemDetail.org.springframework.http.converter.HttpMessageNotReadableException=The request body is not valid JSON
problemDetail.org.springframework.beans.TypeMismatchException=Parameter "{0}" has an invalid format
Spring Boot reads messages.properties as UTF-8 and wires it into the handler automatically

How to check: MockMvc tests and responses

@WebMvcTest starts only the web layer — controllers, @ControllerAdvice, JSON converters — without services or a database. We replace the service with @MockitoBean and make it throw the exceptions we need. Spring Boot's OutputCaptureExtension captures the output so we can check that the errorId from the response reached the log together with the stack trace.

src/test/java/com/example/shop/error/GlobalExceptionHandlerTest.java
package com.example.shop.error;

import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.matchesPattern;
import static org.hamcrest.Matchers.not;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
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 com.example.shop.product.ProductController;
import com.example.shop.product.ProductService;
import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

// web slice: controller + GlobalExceptionHandler, the service is replaced with a mock
@WebMvcTest(ProductController.class)
@ExtendWith(OutputCaptureExtension.class)
class GlobalExceptionHandlerTest {

    @Autowired
    MockMvc mvc;

    @MockitoBean
    ProductService service;

    @Test
    void fieldErrorsBecome400WithPointers() throws Exception {
        mvc.perform(post("/api/products").contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"name": " ", "sku": "abc", "price": -5}
                                """))
                .andExpect(status().isBadRequest())
                .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(jsonPath("$.type").value("https://api.example.com/problems/validation-error"))
                .andExpect(jsonPath("$.errors", hasSize(3)))
                .andExpect(jsonPath("$.errors[?(@.pointer == '#/name')].detail").value("Name is required"))
                .andExpect(jsonPath("$.errors[?(@.pointer == '#/price')].detail").value("Price must be greater than zero"));
    }

    @Test
    void parameterErrorsBecome400() throws Exception {
        mvc.perform(get("/api/products").param("limit", "500"))
                .andExpect(status().isBadRequest())
                .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(jsonPath("$.errors[0].parameter").value("limit"))
                .andExpect(jsonPath("$.errors[0].detail").value("Must be at most 100"));
    }

    @Test
    void notFoundBecomes404() throws Exception {
        given(service.get(42)).willThrow(new NotFoundException("Product 42 not found"));

        mvc.perform(get("/api/products/42"))
                .andExpect(status().isNotFound())
                .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(jsonPath("$.type").doesNotExist()) // no field means about:blank
                .andExpect(jsonPath("$.title").value("Not Found"))
                .andExpect(jsonPath("$.detail").value("Product 42 not found"))
                .andExpect(jsonPath("$.instance").value("/api/products/42"));
    }

    @Test
    void unknownUrlIsAlsoProblemJson() throws Exception {
        mvc.perform(get("/api/unknown"))
                .andExpect(status().isNotFound())
                .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(jsonPath("$.detail").value("No such URL in this API")); // from messages.properties
    }

    @Test
    void conflictBecomes409WithOwnType() throws Exception {
        given(service.create(any())).willThrow(
                new ConflictException("duplicate-sku", "SKU already taken", "A product with SKU CUP-01 already exists"));

        mvc.perform(post("/api/products").contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"name": "Mug", "sku": "CUP-01", "price": 2500}
                                """))
                .andExpect(status().isConflict())
                .andExpect(jsonPath("$.type").value("https://api.example.com/problems/duplicate-sku"))
                .andExpect(jsonPath("$.title").value("SKU already taken"));
    }

    @Test
    void brokenJsonDoesNotEchoParserDetails() throws Exception {
        mvc.perform(post("/api/products").contentType(MediaType.APPLICATION_JSON).content("{oops"))
                .andExpect(status().isBadRequest())
                .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(jsonPath("$.detail").value("The request body is not valid JSON"));
    }

    @Test
    void unexpectedErrorBecomes500WithoutLeak(CapturedOutput output) throws Exception {
        given(service.get(7)).willThrow(new IllegalStateException("connect to db-prod.internal:5432 failed"));

        String body = mvc.perform(get("/api/products/7"))
                .andExpect(status().isInternalServerError())
                .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(jsonPath("$.errorId").value(matchesPattern("[0-9a-f-]{36}")))
                .andExpect(content().string(not(containsString("db-prod"))))
                .andExpect(content().string(not(containsString("IllegalStateException"))))
                .andReturn().getResponse().getContentAsString();

        // the same errorId is in the log, next to the exception and its stack trace
        String errorId = JsonPath.read(body, "$.errorId");
        assertThat(output).contains(errorId).contains("IllegalStateException: connect to db-prod.internal:5432 failed");
    }
}
terminal
$ docker run --rm -v "$PWD:/app" -v "$HOME/.m2:/root/.m2" -w /app maven:3.10-eclipse-temurin-25 mvn -B package
…
[INFO] Running com.example.shop.error.GlobalExceptionHandlerTest
…
… ERROR … c.e.shop.error.GlobalExceptionHandler    : Unhandled error e6c77069-6ef4-495a-a7cd-6c060fb14c20 in GET /api/products/7

java.lang.IllegalStateException: connect to db-prod.internal:5432 failed
    at com.example.shop.product.ProductService.get(ProductService.java:24) ~[classes/:na]
…
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 24.82 s -- in com.example.shop.error.GlobalExceptionHandlerTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.422 s -- in com.example.shop.ShopApplicationTests
…
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
…
[INFO] BUILD SUCCESS
output fragment: the ERROR log entry is the expected result of the 500 test, and the build passes

The validation error response from the test run (formatted for reading). The order of errors items can change between runs, so the test looks an item up by pointer, not by index:

JSON
{
  "detail": "Check the request parameters",
  "instance": "/api/products",
  "status": 400,
  "title": "Validation failed",
  "type": "https://api.example.com/problems/validation-error",
  "errors": [
    {
      "pointer": "#/sku",
      "detail": "SKU must be 3 to 20 characters: A–Z, 0–9 and hyphen"
    },
    {
      "pointer": "#/name",
      "detail": "Name is required"
    },
    {
      "pointer": "#/price",
      "detail": "Price must be greater than zero"
    }
  ]
}
POST /api/products with the body {"name": " ", "sku": "abc", "price": -5}
JSON
{
  "detail": "Internal server error. Give support this code: 51ba9062-b34e-495f-8c6b-38468fd347c5",
  "instance": "/api/products/7",
  "status": 500,
  "title": "Internal Server Error",
  "errorId": "51ba9062-b34e-495f-8c6b-38468fd347c5"
}
an unexpected error: no exception text, no class name — only a code for support

On the running application (java -jar target/shop-0.0.1-SNAPSHOT.jar) the real service produces the same format: creating a product with the same SKU again returns a 409.

terminal
$ curl -s -X POST localhost:8080/api/products -H "Content-Type: application/json" -d '{"name": "Mug", "sku": "CUP-01", "price": 2500}'
{"id":1,"name":"Mug","sku":"CUP-01","price":2500}

$ curl -s -i -X POST localhost:8080/api/products -H "Content-Type: application/json" -d '{"name": "Mug", "sku": "CUP-01", "price": 2500}'
HTTP/1.1 409 
Content-Type: application/problem+json
Transfer-Encoding: chunked
…

{"detail":"A product with SKU CUP-01 already exists","instance":"/api/products","status":409,"title":"SKU already taken","type":"https://api.example.com/problems/duplicate-sku"}

What not to send to the client, and limitations

  • Stack traces, class names and ex.getMessage() of unexpected errors: the RFC 9457 security section warns against exposing implementation details, and OWASP advises returning a generic message and logging the details on the server
  • SQL text, table names, internal service addresses and library versions
  • Other people's personal data in detail — for example, who owns an email address that is already taken
  • Spring's default technical texts: replace them in messages.properties
  • Errors thrown before the DispatcherServlet — for example, in Spring Security filters — never reach @RestControllerAdvice: the @ExceptionHandler mechanism works at the DispatcherServlet level. Spring Boot's standard /error produces those responses, and they need separate configuration to match.
  • The Exception.class handler will also catch exceptions that are meant to be handled further down the chain, such as AccessDeniedException from @PreAuthorize: you get a 500 instead of a 403. Add a dedicated @ExceptionHandler for such types or rethrow the exception — the Spring documentation describes how a rethrown exception propagates through the rest of the resolver chain.
  • Checking SKU uniqueness in memory is a simplification. With a database, a unique index guarantees uniqueness, and a constraint violation must be turned into a 409 in the same way, without the database error text.
  • errorId is the minimal link between a response and the log. If tracing is enabled in the project, Spring Boot writes a correlation ID to the log, and that is the better value to hand to the client.
  • The UI should show users its own texts chosen by type and status code. RFC 9457 advises against parsing detail programmatically, and for about:blank Spring takes title from the English status phrase — "Not Found".

Links and next step

  • "Spring Boot: a REST API from scratch" — a project with JPA, validation and tests that this handler drops into unchanged.
  • "API design: resources, versions, errors, pagination" — which problem types to define and how to describe the error format in an OpenAPI contract.
  • "Error handling: try/catch, async errors and clear messages" — where to catch errors and how a user-facing message differs from a log entry.
  • Official sources are listed below: the Spring Framework "Error Responses" section, RFC 9457 and the OWASP error handling cheat sheet.
How this was verified5 · passedchecked 12 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.