Custom Problem Types

Give each kind of failure a stable type URI and title so clients can branch on it, and keep the catalogue of problem types in one place.

SyntaxProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, detail); problem.setType(URI.create("https://api.example.com/problems/insufficient-stock")); throw new ErrorResponseException(HttpStatus.CONFLICT, problem, null);

A status code alone is too coarse. A 409 from an orders API could mean the stock ran out, the order already shipped, or someone else edited it first, and a client needs to react differently to each. RFC 9457's answer is the type member: a URI that names the kind of problem. Clients branch on type; title and detail are for people.

Designing a type

  • type: an absolute URI you control, such as https://api.example.com/problems/insufficient-stock. Ideally it resolves to a page explaining the problem and how to fix it. Once published it is part of your contract, like an endpoint path, so never rename it.
  • title: fixed for the type. Do not put ids or numbers in it; that is what detail is for.
  • status: the same as the HTTP status, every time this type is used.
  • Extension members: the data a client needs to act, such as sku and available. Use camelCase names, avoid the five standard names, and document them per type.

Keep only the default about:blank (no type) for errors where the status says everything, such as a plain 404 on an unknown URL.

One catalogue, not scattered strings

The common failure is building type URIs inline in a dozen handlers. Within a year two spellings of the same type exist and clients match on neither. Put every type in one enum or class, as the example does, and create problems only through it. The catalogue doubles as the source for the documentation page.

Localised titles and details

For an ErrorResponseException handled by ResponseEntityExceptionHandler, Spring looks up message codes in the application's MessageSource before writing the body: problemDetail.type., problemDetail.title. and problemDetail. followed by the exception's fully qualified class name. Spring Boot configures that MessageSource from messages.properties. This lets you translate titles per Accept-Language without touching code. The type URI must never be translated.

Mistakes to avoid

  • Using a different type for each endpoint. A type describes a problem, and "insufficient stock" is the same problem from POST /orders and PATCH /carts.
  • Putting internal identifiers, exception class names or database constraint names into extension members.
  • Clients parsing detail text. If they need a value, add it as an extension member.

Example

Example · java
import java.net.URI;

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.ErrorResponseException;

// One place that lists every problem type the API can return.
enum ProblemType {
    INSUFFICIENT_STOCK("insufficient-stock", "Insufficient stock", HttpStatus.CONFLICT),
    PAYMENT_DECLINED("payment-declined", "Payment declined", HttpStatus.PAYMENT_REQUIRED),
    ORDER_ALREADY_SHIPPED("order-already-shipped", "Order already shipped", HttpStatus.CONFLICT);

    private static final String BASE = "https://api.example.com/problems/";

    private final URI type;
    private final String title;
    private final HttpStatus status;

    ProblemType(String slug, String title, HttpStatus status) {
        this.type = URI.create(BASE + slug);
        this.title = title;
        this.status = status;
    }

    ProblemDetail create(String detail) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail);
        problem.setType(type);
        problem.setTitle(title);
        return problem;
    }

    HttpStatus status() {
        return status;
    }
}

// Thrown by the web layer; ResponseEntityExceptionHandler renders the body unchanged.
public class ApiProblemException extends ErrorResponseException {

    public ApiProblemException(ProblemType type, String detail) {
        super(type.status(), type.create(detail), null);
    }

    public ApiProblemException with(String name, Object value) {
        getBody().setProperty(name, value);
        return this;
    }
}

When to use it

  • Let a mobile app tell 'out of stock' apart from 'order already shipped' when both return 409.
  • Publish a documentation page per error type and link to it from the type URI.
  • Serve error titles in German to clients that send Accept-Language: de.

More examples

The response a client receives

A client switches on <code>type</code>, shows <code>title</code> or <code>detail</code> to the user, and reads <code>available</code> to offer a smaller quantity. Nothing in this body depends on how the server is built, so the format survives a rewrite.

Example · json
{
  "type": "https://api.example.com/problems/insufficient-stock",
  "title": "Insufficient stock",
  "status": 409,
  "detail": "Only 2 of SKU-1182 left, 5 requested.",
  "instance": "/api/orders",
  "sku": "SKU-1182",
  "available": 2
}

Titles and details from messages.properties

For an <code>ErrorResponseException</code> subclass, Spring resolves these codes through the <code>MessageSource</code> when <code>ResponseEntityExceptionHandler</code> renders it, using the request's locale. Arguments for <code>{0}</code> and <code>{1}</code> come from the five-argument <code>ErrorResponseException</code> constructor: pass <code>null</code> as the message code to keep the default code shown here, and the values as the <code>Object[]</code>. Translate the title, never the type.

Example · properties
# src/main/resources/messages.properties
# Codes are problemDetail.type.<class>, problemDetail.title.<class> and problemDetail.<class>.
problemDetail.type.com.example.orders.web.OrderShippedException=https://api.example.com/problems/order-already-shipped
problemDetail.title.com.example.orders.web.OrderShippedException=Order already shipped
problemDetail.com.example.orders.web.OrderShippedException=Order {0} left the warehouse on {1} and can no longer be changed.

# src/main/resources/messages_de.properties
problemDetail.title.com.example.orders.web.OrderShippedException=Bestellung bereits versandt

Pinning the type URI in a test

Asserting on <code>$.type</code> rather than on <code>detail</code> tests the part of the response clients actually depend on, and lets you reword messages freely. The empty <code>ProblemAdvice</code> subclass is enough: <code>ResponseEntityExceptionHandler</code> already knows how to render any <code>ErrorResponseException</code>.

Example · java
import static org.assertj.core.api.Assertions.assertThat;

import java.net.URI;
import java.util.List;

import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.web.ErrorResponseException;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

class PaymentDeclinedException extends ErrorResponseException {
    PaymentDeclinedException() {
        super(HttpStatus.PAYMENT_REQUIRED, problem(), null);
    }

    private static ProblemDetail problem() {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.PAYMENT_REQUIRED, "The card issuer declined the payment.");
        problem.setType(URI.create("https://api.example.com/problems/payment-declined"));
        problem.setTitle("Payment declined");
        return problem;
    }
}

@RestController
class CheckoutController {
    @PostMapping("/api/checkout")
    void checkout() {
        throw new PaymentDeclinedException();
    }
}

@RestControllerAdvice
class ProblemAdvice extends ResponseEntityExceptionHandler {
}

class ProblemTypeTest {

    private final MockMvcTester mvc = MockMvcTester.of(List.of(new CheckoutController()),
            builder -> builder.setControllerAdvice(new ProblemAdvice()).build());

    @Test
    void declinedPaymentHasStableType() {
        assertThat(mvc.post().uri("/api/checkout"))
                .hasStatus(HttpStatus.PAYMENT_REQUIRED)
                .bodyJson()
                .extractingPath("$.type")
                .isEqualTo("https://api.example.com/problems/payment-declined");
    }
}

Discussion

  • Be the first to comment on this lesson.