Last year I published a Quarkus RFC 7807 error-handling tutorial. I wrote three exception mappers: one for technical failures, one for business failures, and one for Bean Validation. That version gives me full control. It also leaves the Problem Details DTO, Jackson safety, logging, OpenAPI wiring, and every later exception category in my application.
Quarkus 3.38 added quarkus-http-problem to the Platform. I can now leave common exception mapping to the extension. Business errors stay explicit, and I still decide what enters the JSON.
The split is simple. I use HTTP Problem for common exception translation. I keep a direct Response for deliberate HTTP responses and a focused ExceptionMapper for application-specific cases.
What We Build
I use a small warehouse reservation API with one endpoint: POST /reservations. A successful request returns 201. Every error uses application/problem+json:
Unreadable JSON returns
400Bean Validation returns
422with aviolationsarrayAn unknown SKU returns
404Insufficient stock returns
409with a stable problem type and domain fieldsAn unexpected inventory failure returns
500with asupportId
The 500 response hides the internal exception message. I add the support ID with a small post-processor, and Dev UI shows me where that processor sits in the pipeline.
What You Need
I tested this article with Quarkus 3.39.1 and Java 25. The 3.39.1 platform BOM manages quarkus-http-problem 3.38.2, so I leave the extension version out of the POM. The sample keeps inventory in memory because I want to stay on the HTTP error path. You need no database or container.
JDK 25 installed
Quarkus CLI 3.39.x
Basic Jakarta REST and Bean Validation knowledge
About ☕️☕️
Create the Application
I use the platform BOM, io.quarkus.platform:quarkus-bom. The core BOM, io.quarkus:quarkus-bom, leaves this extension unmanaged. Maven then asks for an explicit version.
Create the project or start from my Github repository:
quarkus create app com.themainthread:reservation-problem-contract \
--platform-bom=io.quarkus.platform:quarkus-bom:3.39.1 \
--java=25 \
--no-code \
--extensions=rest-jackson,hibernate-validator,smallrye-openapi,io.quarkiverse.httpproblem:quarkus-http-problem
cd reservation-problem-contractThe command adds these extensions:
quarkus-rest-jacksonfor the JSON APIquarkus-hibernate-validatorfor request constraintsquarkus-smallrye-openapiso error responses pick up the Problem Details schemaquarkus-http-problemfor RFC 9457 mapping, serializers, logging, and Dev UI
The generated dependency has no version because the platform BOM supplies it:
<dependency>
<groupId>io.quarkiverse.httpproblem</groupId>
<artifactId>quarkus-http-problem</artifactId>
</dependency>Existing quarkus-resteasy-problem applications need new coordinates, packages, and config keys. I collect those changes in the migration section below. The extension also has its own migration notes.
Who Owns the Error Response
Before I add code, I want to make the ownership clear. My earlier posts used four ways to create an HTTP error. Each choice puts the status and body in a different place.
I use a direct Response when the resource must set the status, headers, media type, and body. It fits successful payloads, redirects, cache headers, and endpoints that need the full HTTP exchange. Error POJOs can drift when every endpoint creates its own version.
A custom ExceptionMapper puts translation in one place. I still own the protocol fields, JSON safety, logging, OpenAPI wiring, and mapper conflicts. That was the design in my RFC 7807 tutorial.
quarkus-resteasy-problem standardized application/problem+json and introduced HttpProblem. Older apps may still use io.quarkiverse.resteasy-problem, the io.quarkiverse.resteasy.problem package, and quarkus.resteasy.problem.*.
quarkus-http-problem is the current Platform extension. It supplies common framework mappers, serializers, safe details, schema integration, logging, post-processors, and Dev UI. I still choose the business status codes, stable problem types, client-visible fields, and any mapper I replace.
I only need a short RFC 9457 reminder here. A problem response has type, title, status, detail, and instance. It may also contain extra fields. Clients use the type URI as a stable identifier. My earlier RFC 9457 contract article explains registries and OpenAPI error catalogs in more detail.
Add the Reservation Boundary
I start with the request boundary. Create src/main/java/com/themainthread/reservation/ReservationRequest.java:
package com.themainthread.reservation;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record ReservationRequest(
@NotBlank String sku,
@NotNull @Min(1) Integer quantity) {
}I keep the validation rules on the request record. When they fail, the extension maps ConstraintViolationException to Problem Details.
Create src/main/java/com/themainthread/reservation/Reservation.java:
package com.themainthread.reservation;
public record Reservation(String sku, int quantity, int remaining) {
}Create src/main/java/com/themainthread/reservation/InventoryService.java:
package com.themainthread.reservation;
import java.net.URI;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import io.quarkiverse.httpproblem.HttpProblem;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.core.Response;
@ApplicationScoped
public class InventoryService {
static final String LEDGER_OFFLINE_SKU = "ledger-offline";
static final URI INSUFFICIENT_STOCK_TYPE = URI
.create("https://errors.example.com/insufficient-stock");
private final Map<String, Integer> stock = new ConcurrentHashMap<>(Map.of(
"keyboard-1", 2,
"mouse-1", 10));
public Reservation reserve(String sku, int quantity) {
if (LEDGER_OFFLINE_SKU.equals(sku)) {
throw new IllegalStateException("Inventory ledger is unreachable");
}
Integer available = stock.get(sku);
if (available == null) {
throw new NotFoundException("Unknown SKU: " + sku);
}
if (quantity > available) {
throw HttpProblem.builder()
.withType(INSUFFICIENT_STOCK_TYPE)
.withTitle("Insufficient stock")
.withStatus(Response.Status.CONFLICT)
.withDetail("The requested quantity is no longer available.")
.with("sku", sku)
.with("requested", quantity)
.with("available", available)
.build();
}
int remaining = available - quantity;
stock.put(sku, remaining);
return new Reservation(sku, quantity, remaining);
}
}This service has three failure paths. The extension already knows the Jakarta REST NotFoundException. I use HttpProblem for the stock conflict because I want an explicit status, type URI, detail, and set of domain fields. The IllegalStateException represents an unexpected failure, so the default mapper turns it into a generic 500.
Create src/main/java/com/themainthread/reservation/ReservationResource.java:
package com.themainthread.reservation;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/reservations")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class ReservationResource {
private final InventoryService inventory;
public ReservationResource(InventoryService inventory) {
this.inventory = inventory;
}
@POST
@APIResponse(responseCode = "409", description = "The requested quantity is no longer available")
public Response reserve(@Valid ReservationRequest request) throws NotFoundException {
Reservation reservation = inventory.reserve(request.sku(), request.quantity());
return Response.status(Response.Status.CREATED).entity(reservation).build();
}
}The @APIResponse for 409 has no content block. SmallRye OpenAPI is on the classpath, so the extension adds application/problem+json and the HttpProblem schema. The throws NotFoundException declaration adds 404 in the same way.
I start with the successful path. Run the application:
./mvnw quarkus:devReserve one mouse:
curl -i -X POST http://localhost:8080/reservations \
-H 'Content-Type: application/json' \
--data '{"sku":"mouse-1","quantity":1}'The response is 201 Created:
{"sku":"mouse-1","quantity":1,"remaining":9}Configure the Contract
Create src/main/resources/application.properties:
quarkus.http-problem.constraint-violation.status=422
quarkus.http-problem.constraint-violation.title=Validation failed
quarkus.http-problem.include-details=false
quarkus.http-problem.logging.level.4xx=INFO
quarkus.http-problem.logging.level.5xx=ERROR
quarkus.http-problem.logging.include-stack-trace=5xxQuarkus fixes all quarkus.http-problem.* settings at build time. Dev mode applies a change during reload. A packaged application needs a rebuild, so runtime config alone cannot change mapper selection, validation status, schemas, or logging policy.
include-details=false is the default, and I keep it that way. When a Jackson databind mapper runs, detail becomes Malformed request body. Java class names and parser messages stay out of the response.
The validation keys change Hibernate Validator responses from 400 Bad Request to 422 Unprocessable Entity. A client can now separate a parse failure from a constraint failure.
I set log levels by status class. A 404 or validation error stays at INFO. An unexpected 500 stays at ERROR and includes its stack trace. Exact codes are possible too, for example quarkus.http-problem.logging.level.401=WARN.
Let the Extension Handle Framework Failures
First, send a truncated body:
curl -i -X POST http://localhost:8080/reservations \
-H 'Content-Type: application/json' \
--data '{'Quarkus REST wraps this failure in a WebApplicationException. The extension turns it into Problem Details:
{
"status": 400,
"title": "Bad Request",
"detail": "HTTP 400 Bad Request",
"instance": "/reservations"
}The body contains neither JsonParseException nor ReservationRequest. Its Content-Type is application/problem+json. The detail, HTTP 400 Bad Request, comes from the Jakarta REST exception.
Next, send a body that Jackson can tokenize but cannot bind:
curl -i -X POST http://localhost:8080/reservations \
-H 'Content-Type: application/json' \
--data '{"sku":"keyboard-1","quantity":"five"}'This request reaches the Jackson InvalidFormatException mapper. With include-details=false, the response is:
{
"status": 400,
"title": "Bad Request",
"detail": "Malformed request body",
"instance": "/reservations",
"field": "quantity"
}Setting include-details=true includes Jackson’s original message and Java type names in detail. The field member still names the failed input field. I keep the shorter detail because clients cannot fix a Java type name.
A quantity of 0 is valid JSON, so parsing succeeds. Bean Validation rejects the value:
curl -i -X POST http://localhost:8080/reservations \
-H 'Content-Type: application/json' \
--data '{"sku":"keyboard-1","quantity":0}'The response is 422 Unprocessable Entity:
{
"status": 422,
"title": "Validation failed",
"instance": "/reservations",
"violations": [
{
"field": "quantity",
"in": "body",
"message": "must be greater than or equal to 1"
}
]
}The request record has @Min(1), and the extension creates the violations array. The http-problem log records this 422 at INFO. I keep input mistakes out of error alerts because the client can correct them.
Keep Domain Failures Explicit
I use the built-in NotFoundException mapper for an unknown SKU:
curl -i -X POST http://localhost:8080/reservations \
-H 'Content-Type: application/json' \
--data '{"sku":"widget-9","quantity":1}'{
"status": 404,
"title": "Not Found",
"detail": "Unknown SKU: widget-9",
"instance": "/reservations"
}Insufficient stock needs a different contract. Retrying a 404 only adds another failed call. With a stable problem type, the client can identify the stock conflict and offer a smaller quantity.
curl -i -X POST http://localhost:8080/reservations \
-H 'Content-Type: application/json' \
--data '{"sku":"keyboard-1","quantity":5}'{
"type": "https://errors.example.com/insufficient-stock",
"status": 409,
"title": "Insufficient stock",
"detail": "The requested quantity is no longer available.",
"instance": "/reservations",
"sku": "keyboard-1",
"requested": 5,
"available": 2
}The extension writes the JSON and sets instance from the request path. InventoryService supplies status 409, the type URI, and the three domain fields.
If the service also threw NotFoundException for low stock, the client could not separate a missing SKU from a real SKU with too little stock. The status and type make the difference clear.
OpenAPI follows the same ownership split. Fetch the schema:
curl -s http://localhost:8080/q/openapi -H 'Accept: application/json'The declared 409 response includes the Problem Details media type, even though I did not write a content block:
{
"description": "The requested quantity is no longer available",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/HttpProblem"
}
}
}
}The throws NotFoundException declaration adds a 404 entry with the same media type.
Give Server Errors a Support ID
The default mapper handles exceptions with no specific mapper by returning HttpProblem.valueOf(INTERNAL_SERVER_ERROR). The client gets the title and status. The exception message stays in the log. I also want one ID in both places so a support request can point to the correct log entry.
Create src/main/java/com/themainthread/reservation/SupportIdPostProcessor.java:
package com.themainthread.reservation;
import java.util.UUID;
import io.quarkiverse.httpproblem.HttpProblem;
import io.quarkiverse.httpproblem.postprocessing.ProblemContext;
import io.quarkiverse.httpproblem.postprocessing.ProblemPostProcessor;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class SupportIdPostProcessor implements ProblemPostProcessor {
static final int PRIORITY = 50;
@Override
public int priority() {
return PRIORITY;
}
@Override
public HttpProblem apply(HttpProblem problem, ProblemContext context) {
if (problem.getStatusCode() < 500) {
return problem;
}
if (problem.getParameters().containsKey("supportId")) {
return problem;
}
return HttpProblem.builder(problem)
.with("supportId", UUID.randomUUID().toString())
.build();
}
}Post-processors run after the mapper and before serialization. Larger priority values run first. The order is:
MdcPropertiesInjectorat100ProblemDefaultsProviderat99, which fillsinstancefrom the request pathApplication processors
ProblemLoggerat0
Priority 50 places my processor after the defaults and before the logger. The logger therefore sees the same supportId that the client receives.
Trigger the inventory failure:
curl -i -X POST http://localhost:8080/reservations \
-H 'Content-Type: application/json' \
--data '{"sku":"ledger-offline","quantity":1}'The client receives:
{
"status": 500,
"title": "Internal Server Error",
"instance": "/reservations",
"supportId": "e8b476d8-eedb-4c39-ac5f-113a990bd4e7"
}The JSON has no detail, IllegalStateException, or “Inventory ledger is unreachable.” The log records the stack trace and the same ID at ERROR:
ERROR [http-problem] status=500, title="Internal Server Error", instance="/reservations", supportId="e8b476d8-eedb-4c39-ac5f-113a990bd4e7": java.lang.IllegalStateException: Inventory ledger is unreachableAn exception message in the HTTP body can expose internal details. I return the support ID instead, which gives the support team a direct log search value.
Inspect the Pipeline in Dev UI
I open http://localhost:8080/q/dev-ui/. The Http Problem card has three pages.
Exception Mappers lists the active mapper for each exception type. This is the fastest way to spot an unexpected mapper before an API test finds it.
Post Processors shows the execution order. With the support-ID bean in place, I see:
Order 1:
MdcPropertiesInjector, priority100Order 2:
ProblemDefaultsProvider, priority99Order 3:
SupportIdPostProcessor_ClientProxy, priority50Order 4:
ProblemLogger, priority0
The _ClientProxy suffix belongs to the CDI client proxy that Quarkus generates for the @ApplicationScoped bean. SupportIdPostProcessor supplies priority 50.
Test builds an HttpProblem for a selected status and sends it through the listed processors. I generate a 500 and get instance /dev-ui/test plus a supportId. This checks the pipeline without adding a diagnostics endpoint to the application.
The pages call the JSON-RPC methods getPostProcessors and testProblem. I also checked the Dev MCP endpoint in 3.38.2. Its default tool list does not expose these two methods, so I verify the same pipeline by injecting PostProcessorsRegistry in a test.
Two Ways to Bypass the Contract
Two common code paths skip the mapper layer completely. The extension cannot change a response it never receives.
Jakarta REST says that a WebApplicationException whose Response already has an entity is returned as-is. Create src/main/java/com/themainthread/reservation/ContractBypassResource.java:
package com.themainthread.reservation;
import java.util.Map;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
@Path("/demo")
public class ContractBypassResource {
@POST
@Path("/entity-bypass")
public void entityBypass() {
throw new WebApplicationException(
Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("message", "This request is bad"))
.build());
}
@POST
@Path("/status-bypass")
public void statusBypass() {
throw new WebApplicationException(Response.Status.BAD_REQUEST);
}
}The entity form returns application/json and {"message":"This request is bad"}. It has no title, instance, or problem media type. The entity-less form reaches WebApplicationExceptionMapper and returns application/problem+json. I throw HttpProblem or an entity-less JAX-RS exception when I want Problem Details.
The second bypass replaces Jackson’s ObjectMapper with a CDI producer. This can remove the extension serializers and expose exception internals in the payload. For extra Jackson modules, I customize the mapper that Quarkus already created:
package com.themainthread.reservation;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import io.quarkus.jackson.ObjectMapperCustomizer;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class ReservationJacksonCustomizer implements ObjectMapperCustomizer {
@Override
public void customize(ObjectMapper mapper) {
mapper.registerModule(new JavaTimeModule());
}
}The Quarkus REST JSON guide uses the same pattern. If your application must produce its own ObjectMapper, inject every ObjectMapperCustomizer and apply each one.
If one exception type needs an application mapper, disable the built-in mapper with quarkus.http-problem.mapper.<exception-name>.enabled=false. I keep one mapper for each exception type because duplicate registrations make selection harder to reason about.
Migrate Existing Applications
The migration is mostly a rename:
io.quarkiverse.resteasy-problem:quarkus-resteasy-problem
-> io.quarkiverse.httpproblem:quarkus-http-problem
io.quarkiverse.resteasy.problem.*
-> io.quarkiverse.httpproblem.*
quarkus.resteasy.problem.*
-> quarkus.http-problem.*The new extension properties are build-time settings. An application that only consumed the old extension transitively may need only the dependency change.
Prove the Contract
I use @QuarkusTest and REST Assured against real HTTP responses. The tests cover the 201 path and every error shown above. They also check the OpenAPI media type for 409, the processor order, and the generated instance and supportId on a pipeline-created 500.
Run them:
./mvnw testExpected summary:
Tests run: 13, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESSThe HTTP tests check the client contract. The pipeline test checks the processor order, so it proves that ProblemLogger receives the support ID before it writes the ERROR line.
Production Boundaries
I keep include-details=false. Stack traces stay in 5xx logs, and clients get a support ID.
A WebApplicationException with a response entity skips Jakarta REST exception mappers. The test for /demo/entity-bypass keeps this behavior visible.
When my application owns an exception type, I disable the built-in mapper. Registering a second mapper makes the result depend on provider selection.
Mapper selection, schemas, validation status, and logging policy are fixed when I package the application. A change to these properties needs a rebuild.
The in-memory inventory has a separate limit. ConcurrentHashMap protects the map, but get followed by put is not an atomic reservation. Two requests can read the same stock value and both succeed. A real inventory service needs an atomic database update, optimistic locking, or another concurrency control.
A literal JSON null also passes deserialization as a null request. The resource parameter has @Valid but no @NotNull, so dereferencing it produces a 500. Add @NotNull to the parameter when the API must report a null body as a validation error.
Finally, errors.example.com is only a sample URI. I use a stable URI under a domain owned by the API team in a real service.
Conclusion
My RFC 7807 tutorial kept a DTO and three mappers inside the application. The platform extension now handles the repeated mapping, safe JSON defaults, logs, and OpenAPI wiring. I still define the business problem types and every client-visible field.
I still use a direct Response and a focused custom mapper when an endpoint needs them. For common exception-to-error mapping, I start with HTTP Problem because Dev UI lets me inspect the registered pipeline.


