
Reusable Spring Boot auto-configuration that turns exceptions into structured RFC 9457 Problem Details responses and propagates the Micrometer trace ID to every response.
title: Reusable Spring Boot Error Handler published: true description: Reusable Spring Boot auto-configuration that turns exceptions into structured RFC 9457 Problem Details responses and propagates the Micrometer trace ID to every response. tags: springboot, errorhandler, problemdetails, rfc9457
Out of the box, Spring Boot returns a plain 500 Internal Server Error for most unhandled exceptions, and its default BasicErrorController produces a flat JSON object that varies between Spring versions and reveals internal details such as exception class names and stack-trace snippets. This makes it hard for API clients to handle errors programmatically and exposes information that should stay server-side.
This library replaces that behaviour with a single, consistent error contract across every failure mode your API can encounter:
code field (e.g. INVALID_ENUM_VALUE, VALIDATION_FAILED, DUPLICATE_VALUE). Clients can branch on code without parsing human-readable messages.$.address.street), the rejected value, the expected type, and — for enums — the full list of valid values. Validation errors list every constraint violation in one response so clients do not need to submit the form multiple times to discover all problems.traceId) and onto every HTTP response (X-Trace-Id header), so support teams can correlate a client-side error report directly to a distributed trace.@Import, no @ComponentScan, no boilerplate. Spring Boot picks up the handlers automatically.| Dependency | Notes |
|---|---|
| Spring Boot 4.x | Tested on 4.0.6 |
spring-boot-starter-webmvc | Required |
spring-boot-starter-validation | Required for ValidationExceptionHandler |
spring-boot-starter-data-jpa | Required for DataExceptionHandler |
spring-boot-starter-actuator + spring-boot-micrometer-tracing-brave + micrometer-tracing-bridge-brave | Optional — enables traceId in bodies and X-Trace-Id header |
Copy the com.example.demo.error package into your project. Spring Boot's component scan will pick up all @RestControllerAdvice and @Component beans automatically, provided the package is within your application's scan root.
Packaging as a reusable JAR. If you extract these classes into a shared library, register them in
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports(one fully-qualified class name per line). Spring Boot reads that file at startup and activates the listed classes as auto-configurations, so consumers of the JAR need no@Importor@ComponentScan— the dependency alone is sufficient.
JsonExceptionHandler — 400 Bad RequestHandles HttpMessageNotReadableException (Jackson deserialization failures). Maps the Jackson 3 cause chain to a structured response:
| Cause | code | Extra fields |
|---|---|---|
UnrecognizedPropertyException | UNKNOWN_JSON_FIELD | path, validValues |
InvalidFormatException (enum) | INVALID_ENUM_VALUE | path, invalidValue, expectedType, validValues |
InvalidFormatException (scalar) | INVALID_FIELD_VALUE | path, invalidValue, expectedType |
MismatchedInputException | TYPE_MISMATCH | path, expectedType |
InputCoercionException | INTEGER_OVERFLOW | validRange, line, column |
StreamReadException | MALFORMED_JSON | line, column |
| Other | MALFORMED_REQUEST_BODY | — |
{
"type": "about:blank",
"title": "Invalid enum value",
"status": 400,
"detail": "Cannot deserialize value 'UNKNOWN' as Category at path $.category — valid values: [ELECTRONICS, BOOKS, CLOTHING]",
"instance": "/products",
"code": "INVALID_ENUM_VALUE",
"path": "$.category",
"invalidValue": "UNKNOWN",
"expectedType": "Category",
"validValues": [
"ELECTRONICS",
"BOOKS",
"CLOTHING"
],
"traceId": "69fcf2db21f488679d633abb34871dbb"
}
ValidationExceptionHandler — 422 Unprocessable ContentHandles MethodArgumentNotValidException (@Valid on @RequestBody) and HandlerMethodValidationException (@Validated on individual parameters).
{
"type": "about:blank",
"title": "Validation failed",
"status": 422,
"detail": "One or more fields failed validation",
"instance": "/products",
"code": "VALIDATION_FAILED",
"violations": [
{
"path": "$.name",
"invalidValue": "",
"message": "must not be blank"
},
{
"path": "$.price",
"invalidValue": "-1",
"message": "must be greater than 0"
}
],
"traceId": "69fcf2db21f488679d633abb34871dbb"
}
DataExceptionHandler — 404 / 409Handles JPA and JDBC data exceptions. Constraint names are extracted from the PostgreSQL error message via regex; raw database messages are never forwarded to the client.
| Exception | Condition | code | Status |
|---|---|---|---|
DuplicateKeyException | any | DUPLICATE_VALUE | 409 |
DataIntegrityViolationException | unique constraint | DUPLICATE_VALUE | 409 |
DataIntegrityViolationException | foreign key constraint | REFERENTIAL_INTEGRITY_VIOLATION | 409 |
DataIntegrityViolationException | other | DATA_INTEGRITY_VIOLATION | 409 |
EmptyResultDataAccessException | any | RESOURCE_NOT_FOUND | 404 |
EntityNotFoundException | any | RESOURCE_NOT_FOUND | 404 |
GlobalExceptionHandler — Spring MVC infrastructure + catch-allExtends ResponseEntityExceptionHandler to handle the full set of Spring MVC exceptions (405, 415, 400, 404, 408, 413, …) and adds a stable code field to each. A final @ExceptionHandler(Exception.class) returns 500 INTERNAL_SERVER_ERROR and logs the stack trace at ERROR level.
ProblemDetailTraceAdviceResponseBodyAdvice that appends "traceId": "<hex>" to every ProblemDetail body produced by an @ExceptionHandler. Scoped to exception handlers only — normal 2xx responses are not intercepted.
TraceIdResponseHeaderFilterOncePerRequestFilter that sets an X-Trace-Id: <hex> response header on every response (success and error alike). Runs at Ordered.LOWEST_PRECEDENCE so it is guaranteed to execute inside the ServerHttpObservationFilter span context.
# Required for JsonExceptionHandler to produce UNKNOWN_JSON_FIELD responses.
# Without this, Jackson silently ignores unrecognised fields and the handler
# never sees an UnrecognizedPropertyException.
spring.jackson.deserialization.fail-on-unknown-properties=true
# Fraction of requests that receive a trace ID (0.0–1.0).
# Defaults to 0.1 (10 %). Set to 1.0 in development so every request
# gets an X-Trace-Id header and a traceId field in error bodies.
management.tracing.sampling.probability=1.0
# Disable tracing entirely without removing the dependency.
# ProblemDetailTraceAdvice and TraceIdResponseHeaderFilter become no-ops.
# management.tracing.enabled=false
Each handler logs at a level that reflects the severity of the underlying problem. Override individual loggers to tune verbosity:
# JsonExceptionHandler — DEBUG for all recognised causes, WARN for unclassified bodies
logging.level.com.example.demo.error.JsonExceptionHandler=DEBUG
# ValidationExceptionHandler — DEBUG for every violation set
logging.level.com.example.demo.error.ValidationExceptionHandler=DEBUG
# DataExceptionHandler — WARN for constraint violations, DEBUG for not-found
logging.level.com.example.demo.error.DataExceptionHandler=DEBUG
# GlobalExceptionHandler — ERROR for unexpected exceptions (stack trace included),
# everything else inherited from Spring's ResponseEntityExceptionHandler (WARN)
logging.level.com.example.demo.error.GlobalExceptionHandler=DEBUG
# Suppress the stack-trace log that Spring MVC itself emits for resolved exceptions.
# Useful in production to avoid double-logging when GlobalExceptionHandler already logs.
logging.level.org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler=ERROR
JsonExceptionHandler and ValidationExceptionHandler deliberately split their log output into two statements:
log.trace("Stack trace:", ex).This means enabling DEBUG gives you actionable context for every bad request without stack-trace noise. Enable TRACE only when you need to inspect the Jackson or Validator internals (e.g. to diagnose a misconfigured deserializer):
# Stack traces for JSON deserialization failures
logging.level.com.example.demo.error.JsonExceptionHandler=TRACE
# Stack traces for Bean Validation failures
logging.level.com.example.demo.error.ValidationExceptionHandler=TRACE
GlobalExceptionHandler.handleUnexpected is the exception to this pattern: it logs at ERROR with the exception object directly (log.error("Unexpected error", ex)), so the stack trace is always captured for genuinely unexpected failures regardless of the configured level.
PAYLOAD_TOO_LARGE)# Maximum size of a single uploaded file (default: 1MB)
spring.servlet.multipart.max-file-size=10MB
# Maximum size of the entire multipart request (default: 10MB)
spring.servlet.multipart.max-request-size=50MB
When either limit is exceeded Spring throws MaxUploadSizeExceededException, which GlobalExceptionHandler maps to 413 PAYLOAD_TOO_LARGE.
Every title and detail field in the error body is resolved through Spring's MessageSource, with the locale read from LocaleContextHolder at exception-handling time. Translating error messages to a new language requires only a properties file — no code changes.
| File | Locale |
|---|---|
messages.properties | English (fallback) |
messages_pt_BR.properties | Portuguese — Brazil |
Create messages_<language>[_<COUNTRY>].properties alongside the existing files and translate every key. Spring resolves the closest-matching bundle for the request locale automatically.
# src/main/resources/messages_es.properties
error.validation-failed.title=Validación fallida
error.validation-failed.detail=Uno o más campos no pasaron la validación
error.resource-not-found.title=Recurso no encontrado
error.resource-not-found.detail=El recurso solicitado no fue encontrado.
# … remaining keys …
If you package this library as a JAR and your application defines its own messages.properties, list both basenames so Spring merges them:
spring.messages.basename=messages,classpath:com/example/demo/error/messages
spring.messages.encoding=UTF-8
By default, Spring MVC's AcceptHeaderLocaleResolver maps the Accept-Language request header to a java.util.Locale. When the header is absent or no bundle matches, Spring falls back to the JVM default locale and then to the root messages.properties.
To resolve locale from a cookie or session instead, declare a LocaleResolver bean:
@Bean
LocaleResolver localeResolver() {
var resolver = new CookieLocaleResolver("lang");
resolver.setDefaultLocale(Locale.ENGLISH);
return resolver;
}
The violations[].message field inside VALIDATION_FAILED responses comes from Bean Validation, not from the MessageSource above. To localise constraint messages, add a ValidationMessages_<locale>.properties file to your classpath:
# src/main/resources/ValidationMessages_pt_BR.properties
jakarta.validation.constraints.NotBlank.message=não deve estar em branco
jakarta.validation.constraints.Size.message=o tamanho deve estar entre {min} e {max}
Bean Validation resolves its bundle against the JVM default locale. To drive it from the per-request locale, supply a locale-aware MessageInterpolator in your validator configuration.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-micrometer-tracing-brave</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
Without these dependencies the
traceIdbody field andX-Trace-Idheader are simply absent. All other error-handling behaviour is unaffected.
code | Status | Meaning |
|---|---|---|
MALFORMED_JSON | 400 | Syntactically invalid JSON |
MALFORMED_REQUEST_BODY | 400 | Body unreadable (unclassified) |
UNKNOWN_JSON_FIELD | 400 | Field not declared on the target type |
INVALID_ENUM_VALUE | 400 | Value not in enum constants |
INVALID_FIELD_VALUE | 400 | Value cannot be coerced to target type |
TYPE_MISMATCH | 400 | Wrong JSON token type for target |
INTEGER_OVERFLOW | 400 | Numeric value outside type range |
VALIDATION_FAILED | 422 | Bean Validation constraint failure |
METHOD_VALIDATION_ERROR | 422 | Method-level validation failure |
DUPLICATE_VALUE | 409 | Unique constraint violated |
REFERENTIAL_INTEGRITY_VIOLATION | 409 | Foreign key constraint violated |
DATA_INTEGRITY_VIOLATION | 409 | Other integrity constraint violated |
RESOURCE_NOT_FOUND | 404 | Entity or query result not found |
METHOD_NOT_ALLOWED | 405 | HTTP method not supported |
UNSUPPORTED_MEDIA_TYPE | 415 | Content-Type not accepted |
NOT_ACCEPTABLE | 406 | Requested Accept type unavailable |
MISSING_PATH_VARIABLE | 400 | Required path variable absent |
MISSING_REQUEST_PARAMETER | 400 | Required query parameter absent |
MISSING_REQUEST_PART | 400 | Required multipart part absent |
REQUEST_BINDING_ERROR | 400 | Servlet request binding failure |
ROUTE_NOT_FOUND | 404 | No handler mapped for the request path |
REQUEST_TIMEOUT | 503 | Async request timed out |
PAYLOAD_TOO_LARGE | 413 | Upload exceeds configured limit |
CONVERSION_NOT_SUPPORTED | 500 | No converter for property type |
PARAMETER_TYPE_MISMATCH | 400 | Query/path parameter type coercion failed |
MESSAGE_NOT_WRITABLE | 500 | Response body could not be serialised |
INTERNAL_SERVER_ERROR | 500 | Unexpected exception |
The source code of the these handlers is available in GitHub.
gemmaI ported the whole Gemma-4 family — E2B, E4B, 12B, 31B, and the 26B-A4B MoE — to run on...
communityHey DEV, I'm Tobore. Let's actually connect. I've been on here for a while now, mostly writing and...
ai(yep, kinda clickbait, just for the funsies 😊) At the beginning of the year, I relaunched my...
aiMy laptop was sitting idle with the fan at full tilt. Nothing was running that I knew of. The culprit...
githubactionsI Built a Thing! TL;DR — Google Gemini-based Pull Request reviews and Issue Triaging for...
aiI've been hearing the word "harness" thrown around a lot lately. I assumed it just meant "the IDE" or...
Workflows from the Neura Market marketplace related to this DeepSeek resource