Sane API error handling with RFC 9457 Problem Details in Jakarta EE

- 11 mins

When APIs end up with their own error format, it quickly gets annoying for anyone who has to consume more than one API. RFC 9457 defines a standard envelope for HTTP API errors. Let’s have a look at how to do it in Jakarta EE: a small hand-made ProblemDetail plus one ExceptionMapper per error category; with the Zalando Problem library; followed by quick notes on Quarkus and Spring as alternatives.


Introduction

If you’ve consumed more than one or two REST APIs, you’ve seen the pattern. One service returns {"error": "..."}, another {"message": "...", "code": 42}, a third returns 200 OK with an error hidden somewhere deep in the response. Your REST client code fills up with special cases for each one. Sounds familiar?

RFC 9457 – Problem Details for HTTP APIs (the successor to RFC 7807) defines a single JSON envelope for errors, served as application/problem+json MIME type. It is a small spec: five well-defined bits of information and an extensions map for anything else you might need.

{
  "type": "urn:problem-type:validation-error",
  "title": "Validation Failed",
  "status": 400,
  "detail": "The request body or parameters failed validation.",
  "extensions": {
    "violations": [
      { "field": "title", "message": "Title is required" }
    ]
  }
}

TL;DR: Why RFC 9457?

Why not keep creating your own?

💡 Note: RFC 9457 is just a JSON structure and a content type. No library or framework is required. That’s why there are so many implementations – and why a hand-made one is often a reasonable choice.


Let’s write some code!

I have created a repository called API Guide for Java to showcase the patterns for one of my talks. For this post, have a look at ProblemDetail.java and the mappers next to it under com/mehmandarov/confapi/error/.

1. Hand-made ProblemDetail + ExceptionMapper

What it looks like

Imagine you have a REST interface looking like this:

@GET
@Path("/{id}")
@Operation(summary = "Get room by ID")
@APIResponse(responseCode = "200", description = "Room found")
@APIResponse(responseCode = "404", description = "Room not found")
public Room getById(
        @Parameter(description = "Room ID", required = true)
        @PathParam("id") String id) {
    return repo.findById(id)
            .orElseThrow(() -> new NotFoundException("Room not found: " + id));
}

Now, you can add a single ProblemDetail class – built around the five RFC 9457 elements and an extensions map – and one ExceptionMapper per error category.

public class ProblemDetail {
    private URI type = URI.create("about:blank");
    private String title;
    private int status;
    private String detail;
    private URI instance;
    private final Map<String, Object> extensions = new LinkedHashMap<>();

    public static ProblemDetail of(int status, String title) { /* ... */ }
    public ProblemDetail withType(String typeUri)            { /* ... */ }
    public ProblemDetail withExtension(String key, Object v) { /* ... */ }
    // + getters/setters
}

The interesting part is how it gets used. As you can see from the resource code above, there is no try/catch in resources, ever – every exception is turned into a Problem Details response by an ExceptionMapper:

@Provider
public class ConstraintViolationExceptionMapper
        implements ExceptionMapper<ConstraintViolationException> {

    @Override
    public Response toResponse(ConstraintViolationException ex) {
        List<Map<String, String>> violations = ex.getConstraintViolations()
                .stream().map(this::toMap).toList();

        ProblemDetail problem = ProblemDetail.of(400, "Validation Failed")
            .withType("urn:problem-type:validation-error")
            .withExtension("violations", violations);

        return Response.status(400)
                .type("application/problem+json")
                .entity(problem).build();
    }
}

One mapper per category keeps each file small and obvious: ConstraintViolationExceptionMapper → 400, NotFoundExceptionMapper → 404, NotAuthorizedExceptionMapper → 401, ForbiddenExceptionMapper → 403, and a CatchAllExceptionMapper → 500 that never leaks stack traces to clients.

⚠️A word of caution: The catch-all mapper is the safety net for everything you forgot to handle. Without one, an uncaught exception ends up in the server’s default error page, which often includes stack traces, server versions, and sometimes filesystem paths. However, it might be a good idea to handle most of the common exceptions explicitly, and leave the generic catch-all for something truly unexpected.

✅ Pros:

❌ Cons:

💡 Want to know more? The full code, including all five mappers, lives in com/mehmandarov/confapi/error/.


2. Zalando Problem

What it looks like

The Zalando Problem library (org.zalando:problem + jackson-datatype-problem) gives you Problem and ThrowableProblem types and Jackson serialization. You still write an ExceptionMapper to bridge JAX-RS exceptions to Problem, but you don’t define the envelope yourself.

import org.zalando.problem.Problem;
import org.zalando.problem.Status;

Problem problem = Problem.builder()
        .withType(URI.create("urn:problem-type:validation-error"))
        .withTitle("Validation Failed")
        .withStatus(Status.BAD_REQUEST)
        .with("violations", violations)
        .build();

return Response.status(400)
        .type("application/problem+json")
        .entity(problem).build();

✅ Pros:

❌ Cons:


3. Quarkus: quarkus-http-problem

If you’re only targeting Quarkus, the quarkus-http-problem Quarkiverse extension is the shortest path. It auto-maps ConstraintViolationException, WebApplicationException, and uncaught Throwable to application/problem+json with no boilerplate from you.

✅ Pros:

❌ Cons:


4. Spring Boot – a short note

For completeness, we need to mention Spring Boot 3+ as well, which has Problem Details built in as org.springframework.http.ProblemDetail, with content negotiation and @ExceptionHandler integration already wired up. If you’re on Spring, just use it. The JSON structure is the same RFC 9457; only the wiring differs.


Conclusion

The point of RFC 9457 is not that there’s one correct implementation – there are several reasonable ones – but that there’s one correct envelope. Once your API speaks application/problem+json, clients stop hand-coding error parsers for each new service they consume.

A few rules of thumb:

I picked the hand-made approach for the demo project because portability across Quarkus, Helidon, and Open Liberty mattered, and because the ExceptionMapper is the demo – hiding it behind a library would have defeated the point of the talk.

However, “hand-made” doesn’t have to mean “everyone reinvents it from scratch”. Write it once, put it in a small internal library, and reuse it across services. That’s still less code than wiring up a third-party dependency in each runtime.

Summary Comparison

Option What it gives you Runtimes Dependency cost
Hand-made (this post) ~30-line ProblemDetail + one mapper per error category.  ✅ Quarkus  ✅ Helidon  ✅ Open Liberty None
Zalando Problem Problem / ThrowableProblem types + Jackson serialization. You still write the mappers.  ✅ Quarkus  ✅ Helidon  ✅ Open Liberty 1–2 artifacts
quarkus-http-problem Auto-maps validation, WebApplicationException, and uncaught Throwable. No boilerplate.  ✅ Quarkus only 1 extension
Spring ProblemDetail Built into the framework. Content negotiation and @ExceptionHandler integration.  ✅ Spring Boot 3+ None (built in)

What’s Next?

Error handling is one of the bonus topics in the API Guide for Java. The same repo also covers OpenAPI documentation, security (RBAC, JWT), pagination, async, and versioning strategies – see my earlier post on API versioning in Java using JAX-RS.

Happy shipping of well-formed error messages, folks!


Rustam Mehmandarov

Rustam Mehmandarov

Passionate Computer Scientist