RFC 7807 Problem Details in Spring Boot: Standardized API Error Responses
Learn how to implement RFC 7807 Problem Details in Spring Boot 3.x for standardized, machine-readable API error responses.
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
- ✓Java 17 or later
- ✓Spring Boot 3.2+ (with spring-boot-starter-web)
- ✓Basic knowledge of Spring @ControllerAdvice and exception handling
- ✓Postman or curl for testing
โข RFC 7807 defines a standard JSON format for API error responses with fields like type, title, status, detail, and instance. โข Spring Boot 3.x provides ProblemDetail class and ErrorResponse interface to implement this. โข Use @ExceptionHandler in @ControllerAdvice to return ProblemDetail objects. โข Include custom extensions for domain-specific error data. โข Test with mock requests to ensure proper serialization and status codes.
Imagine you call a pizza delivery service and they say 'Sorry, can't deliver.' That's useless. RFC 7807 is like them telling you 'Sorry, we're out of pepperoni (error type), your order #123 (instance) failed because we ran out (detail).' It gives you enough info to fix or escalate without guessing.
You're building a payment-processing API. A client sends a charge request, and your system rejects it. What do you return? Most teams throw back a generic 400 Bad Request with a terse message like 'Invalid input.' That's useless for the clientโthey have to parse the body, guess the error type, and maybe call support. In production, this leads to fragile integrations, angry customers, and late-night debugging because the error format changes between endpoints. Here's the hard truth: most teams get this wrong. They invent their own error schema, document it poorly, and break clients with every release. The industry has a better way: RFC 7807, also known as Problem Details. It's an IETF standard (since 2016) that defines a consistent JSON structure for HTTP API errors. Spring Boot 3.x introduced first-class support via the ProblemDetail class and the ErrorResponse interface, making it trivial to adopt. In this tutorial, you'll learn how to implement RFC 7807 in Spring Boot 3.2+, handle validation errors, add custom extensions for domain-specific data (like 'insufficient_funds' or 'rate_limit_exceeded'), and avoid common pitfalls that will cause your clients to curse your name. We'll use a real incident from a SaaS billing system to show why standardization matters. By the end, you'll have a production-ready error handling strategy that your mobile and frontend teams will thank you for.
Why RFC 7807 Matters for Your API
Before RFC 7807, every API team invented their own error format. You've seen them: {"error": "bad_request"}, {"message": "Invalid input"}, {"status": 400, "code": "VALIDATION_ERROR"}. This chaos makes client integration a nightmare. RFC 7807 (now RFC 9457) defines a standard Problem Details JSON object with fields like type, title, status, detail, and instance. In Spring Boot 3.x, you get first-class support via ProblemDetail and ErrorResponse. Here's the hard truth: most teams get this wrong by not using the type URI correctly โ it should point to a human-readable documentation page, not just a constant string. Example:
``java @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(IllegalArgumentException.class) public ProblemDetail handleIllegalArgument(IllegalArgumentException ex) { ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); pd.setTitle("Invalid Request Parameter"); pd.setDetail(ex.getMessage()); pd.setType(URI.create("https://api.example.com/errors/invalid-param")); pd.setProperty("timestamp", ``Instant.now()); return pd; } }
Output: ``json {"type":"https://api.example.com/errors/invalid-param","title":"Invalid Request Parameter","status":400,"detail":"User ID must be a positive integer","instance":"/api/users/-1","timestamp":"2025-03-15T10:30:00Z"} ``
What the Official Docs Won't Tell You
Spring Boot's official docs show you ProblemDetail but hide the landmines. First, the type field is NOT optional โ if you leave it null, Spring silently defaults it to "about:blank", which breaks spec compliance because about:blank means the problem is undefined. Second, ErrorResponse interface in Spring 6+ has a updateAndGetBody() method that can override your custom fields โ call it explicitly if you use ResponseEntityExceptionHandler. Third, the instance field should be the request URI, not a generic path โ use ServletRequest.getRequestURI(). Fourth, never put stack traces in detail; use a custom extension field like "trace" instead. Here's a gotcha that burned me: if you throw an exception in a @ExceptionHandler, Spring wraps it in another ProblemDetail with status 500, losing your original error. Always log before returning.
``java @ExceptionHandler(DataIntegrityViolationException.class) public ProblemDetail handleConstraintViolation(DataIntegrityViolationException ex, WebRequest request) { ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.CONFLICT); pd.setTitle("Resource Conflict"); pd.setDetail("Duplicate entry detected"); pd.setType(URI.create("https://api.example.com/errors/conflict")); pd.setInstance(URI.create(request.getDescription(false).replace("uri=", ""))); log.warn("Conflict: {}", ex.getMessage()); return pd; } ``
Output: ``json {"type":"https://api.example.com/errors/conflict","title":"Resource Conflict","status":409,"detail":"Duplicate entry detected","instance":"/api/orders/123"} ``
Customizing Problem Details with Extensions
The RFC allows custom extension members โ keys prefixed with x- (though Spring doesn't enforce this). In payment processing, you often need fields like "balance", "retry_after", or "error_code". Spring's ProblemDetail supports arbitrary properties via setProperty(). But beware: extension keys should be documented in your type URI. Never use detail for structured data like a list of validation errors โ use an extension like "errors": [{"field": "email", "message": "invalid format"}]. Here's a realistic example for a SaaS billing API:
``java @ExceptionHandler(PaymentDeclinedException.class) public ProblemDetail handlePaymentDeclined(PaymentDeclinedException ex) { ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.PAYMENT_REQUIRED); pd.setTitle("Payment Declined"); pd.setDetail("Transaction failed due to insufficient funds"); pd.setType(URI.create("https://api.billing.com/errors/payment-declined")); pd.setProperty("x-error-code", "INSUFFICIENT_FUNDS"); pd.setProperty("x-account-id", ex.getAccountId()); pd.setProperty("x-balance", ex.getBalance()); pd.setProperty("x-retry-after", 30); return pd; } ``
Output: ``json {"type":"https://api.billing.com/errors/payment-declined","title":"Payment Declined","status":402,"detail":"Transaction failed due to insufficient funds","instance":"/api/charges","x-error-code":"INSUFFICIENT_FUNDS","x-account-id":"acc_12345","x-balance":5.00,"x-retry-after":30} ``
Global Consistency with ErrorResponse and ProblemDetail
Spring Boot 3.2+ introduced ErrorResponse interface that ProblemDetail implements. This allows you to centralize error handling. Create a base exception class implementing ErrorResponse to ensure every exception has a consistent structure. For a real-time analytics API handling millions of requests, consistency is critical โ clients write parsers once. Here's the pattern: define a ApiException that returns a ProblemDetail, then throw it anywhere:
```java public class ApiException extends RuntimeException implements ErrorResponse { private final HttpStatus status; private final String errorCode; private final URI type;
public ApiException(HttpStatus status, String errorCode, String detail) { super(detail); this.status = status; this.errorCode = errorCode; this.type = URI.create("https://api.example.com/errors/" + errorCode); }
@Override public ProblemDetail getBody() { ProblemDetail pd = ProblemDetail.forStatus(status); pd.setTitle(errorCode.replace("_", " ")); pd.setDetail(getMessage()); pd.setType(type); pd.setProperty("x-error-code", errorCode); return pd; } } ```
Then in a controller: ``java if (queryTimeout) { throw new ApiException(HttpStatus.GATEWAY_TIMEOUT, "QUERY_TIMEOUT", "Query exceeded 30s limit"); } ``
Output: ``json {"type":"https://api.example.com/errors/QUERY_TIMEOUT","title":"QUERY TIMEOUT","status":504,"detail":"Query exceeded 30s limit","instance":"/api/analytics/events","x-error-code":"QUERY_TIMEOUT"} ``
Extending ProblemDetail for Custom Fields
The default RFC 7807 structure covers the basics, but real-world APIs almost always need additional context. For example, in a payment-processing system, when a charge fails, you might want to include the payment_intent_id, decline_code, and retry_after seconds. Spring Boot 3.1+ makes this trivial by subclassing ProblemDetail. Here's the hard truth: most teams get this wrong by shoving everything into the "detail" field as a JSON string, which defeats the purpose of a standardized structure. Instead, create a PaymentProblemDetail that extends ProblemDetail and adds typed fields. Use a static factory method to enforce consistency. For example: PaymentProblemDetail.forDeclinedCharge(paymentIntentId, declineCode, retryAfterSeconds). This ensures every error response has a predictable shape. In your @ExceptionHandler, return this custom type directly. Spring will serialize it correctly, including the extra fields, as long as you use Jackson's @JsonUnwrapped or rely on the base class's properties map. I've seen teams waste hours debugging because they forgot to register the custom serializer. Don't. Just extend and use the properties map from ProblemDetail โ it's designed for this. One gotcha: the properties map is not thread-safe by default, so in a reactive stack like WebFlux, you need to ensure you're not sharing instances across threads. Use a factory method that creates a new instance per request.
ProblemDetail for domain-specific fields, use factory methods, and rely on the properties map for extensibility.Global Exception Handler with ProblemDetail
The standard approach is a @RestControllerAdvice that catches exceptions and returns ProblemDetail. But here's the problem: many tutorials show you how to handle MethodArgumentNotValidException by returning a generic 400 Bad Request. In a real SaaS billing API, you need to return validation errors as a list of problems, not a single one. RFC 7807 supports this with the 'errors' array extension. Spring Boot's ErrorResponse interface makes this clean. Create a handler for ValidationException that builds a ProblemDetail with status 422 and a list of field-level errors in the properties map. I've seen teams try to use BindingResult directly in the handler, which works but leaks framework internals. Instead, wrap it in a domain-specific ValidationErrorResponse. Another trap: @ExceptionHandler methods must return ProblemDetail or ResponseEntity<ProblemDetail> โ if you return a raw ResponseEntity, Spring will not apply the media type application/problem+json automatically. Always set the Content-Type header explicitly in production. For 500 errors, never expose stack traces in the response. Use a generic 'Internal Server Error' and log the full stack trace server-side. I once had a junior dev accidentally expose database connection strings in a production error response because they returned e.getMessage() directly. Don't be that person.
@RestControllerAdvice and return ProblemDetail with structured error details, not raw stack traces.Integrating with Spring Security Exceptions
Spring Security exceptions like AccessDeniedException and AuthenticationException are notoriously tricky to handle because they occur before your @ControllerAdvice is invoked. The default behavior returns a generic 403 or 401 HTML page, which is useless for a REST API. You must configure the ExceptionTranslationFilter to use a custom AuthenticationEntryPoint and AccessDeniedHandler that return ProblemDetail. Here's the hard truth: most teams forget to set the media type to application/problem+json in these handlers, so clients get a plain text response. In Spring Boot 3.2+, you can use ProblemDetail directly in the method. For authentication failures, return a 401 with a commence()ProblemDetail that includes a 'realm' property. For authorization failures, return a 403 with a detail explaining the missing permission. I've seen production outages where a 403 was returned without any body, and the frontend team spent hours debugging why the error wasn't showing. Always include a 'required_role' or 'missing_permission' property to help clients. Another gotcha: if you're using method security (@PreAuthorize), the exception is thrown inside the controller advice scope, so your @ExceptionHandler will catch it โ but only if you've registered it correctly. Test this with a mock security context. Don't assume it works.
ProblemDetail to ensure consistent JSON responses for auth failures.Testing ProblemDetail Responses
You can't ship a robust API without testing your error responses. The naive approach is to test only the happy path. In a billing system, you need to verify that a 402 Payment Required returns exactly the right ProblemDetail structure. Use MockMvc or WebTestClient (for WebFlux) to send requests that trigger exceptions and assert on the response body. The key is to use jsonPath to verify individual fields like $.title and $.status. But here's the gotcha: Spring's default ProblemDetail serialization includes type as "about:blank" when not set explicitly. If you change it, your tests must account for that. I've seen teams write brittle tests that fail because they hardcoded "about:blank" and later customized the type. Use @Nested test classes to organize tests by exception type. Another mistake: testing only the structure, not the values. Always assert that the detail field contains meaningful information, not just a generic string. For example, when testing a validation error, assert that the 'errors' array includes the exact field name and message. Use @ParameterizedTest to test multiple scenarios. In production debugging, a well-tested error response can save hours of investigation. One more thing: don't forget to test the media type. Use andExpect(.content().contentType("application/problem+json"))
The $50K Integration Failure: When Error Formats Break Production
- Standardize error formats earlyโdon't invent your own unless you have to.
- Use RFC 7807 to make errors machine-readable and actionable for clients.
- Test error responses with mock clients to ensure consistency across endpoints.
text/html instead of application/problem+json@ExceptionHandler returns ProblemDetail and not ResponseEntity without setting Content-Type. Also verify filter-level handlers for Spring Security.setProperty() on the ProblemDetail instance, not setting fields directly. Verify that Jackson is not configured to ignore unknown properties.server.error.include-stacktrace in application.properties and override the generic handler to return a safe ProblemDetail.curl -v http://localhost:8080/api/test | grep Content-Typegrep -r "@ExceptionHandler" src/main/java/ProblemDetail and remove manual ResponseEntity wrapping.| File | Command / Code | Purpose |
|---|---|---|
| GlobalExceptionHandler.java | @RestControllerAdvice | Why RFC 7807 Matters for Your API |
| ConflictHandler.java | @ExceptionHandler(DataIntegrityViolationException.class) | What the Official Docs Won't Tell You |
| PaymentExceptionHandler.java | @ExceptionHandler(PaymentDeclinedException.class) | Customizing Problem Details with Extensions |
| ApiException.java | public class ApiException extends RuntimeException implements ErrorResponse { | Global Consistency with ErrorResponse and ProblemDetail |
| PaymentProblemDetail.java | public class PaymentProblemDetail extends ProblemDetail { | Extending ProblemDetail for Custom Fields |
| SecurityProblemHandler.java | @Component | Integrating with Spring Security Exceptions |
| ProblemDetailTest.java | @WebMvcTest(controllers = PaymentController.class) | Testing ProblemDetail Responses |
Key takeaways
ProblemDetail for standardized, RFC 7807-compliant error responses across all endpoints.ProblemDetail for domain-specific fields using the properties map, not by overriding serialization.AuthenticationEntryPoint and AccessDeniedHandler.Interview Questions on This Topic
How does Spring Boot's ProblemDetail support differ from custom error response classes?
ProblemDetail implements RFC 7807 out of the box, providing a standardized structure (type, title, status, detail, instance) that clients can parse uniformly. Custom classes require manual serialization and lack built-in Spring integration like @ExceptionHandler auto-detection.Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
That's Spring Boot. Mark it forged?
6 min read · try the examples if you haven't