Real World Example

Consistent Errors in Production APIs

Stripe, Razorpay and GitHub all return one predictable error format with a machine-readable code, so the app can show the right message ("Card declined", "Out of stock") and support teams can trace the request. ShopEase does the same with ProblemDetail plus an errorCode and never leaks stack traces.

ShopEase: The Complete Handler

package com.shopease.common.error;

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(ResourceNotFoundException.class)
    public ProblemDetail notFound(ResourceNotFoundException ex) {
        return problem(HttpStatus.NOT_FOUND, "Resource not found", "NOT_FOUND", ex.getMessage());
    }

    @ExceptionHandler(InsufficientStockException.class)
    public ProblemDetail outOfStock(InsufficientStockException ex) {
        return problem(HttpStatus.CONFLICT, "Insufficient stock", "OUT_OF_STOCK", ex.getMessage());
    }

    @ExceptionHandler(DuplicateSkuException.class)
    public ProblemDetail duplicate(DuplicateSkuException ex) {
        return problem(HttpStatus.CONFLICT, "Duplicate SKU", "DUPLICATE_SKU", ex.getMessage());
    }

    @ExceptionHandler(Exception.class)                 // safety net
    public ProblemDetail unexpected(Exception ex) {
        log.error("Unexpected error", ex);              // full stack trace goes to the log only
        return problem(HttpStatus.INTERNAL_SERVER_ERROR, "Internal error", "INTERNAL",
                "Something went wrong. Please try again.");
    }

    private ProblemDetail problem(HttpStatus status, String title, String code, String detail) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(status, detail);
        pd.setTitle(title);
        pd.setType(URI.create("https://shopease.in/errors/" + code.toLowerCase().replace('_', '-')));
        pd.setProperty("errorCode", code);
        return pd;
    }
}

Every Error Looks the Same

Example

curl localhost:8080/api/products/42
curl -X POST localhost:8080/api/products/1/reserve -H "Content-Type: application/json" -d '{"quantity":500}'
curl -X POST localhost:8080/api/products -H "Content-Type: application/json" -d '{"sku":"ELE-1001","name":"Mouse","price":799.00,"stock":5,"category":"Electronics"}'

Example output

{"type":"https://shopease.in/errors/not-found","title":"Resource not found","status":404,"detail":"Product 42 not found","instance":"/api/products/42","errorCode":"NOT_FOUND"}
{"type":"https://shopease.in/errors/out-of-stock","title":"Insufficient stock","status":409,"detail":"Only 120 left of Wireless Mouse, requested 500","instance":"/api/products/1/reserve","errorCode":"OUT_OF_STOCK"}
{"type":"https://shopease.in/errors/duplicate-sku","title":"Duplicate SKU","status":409,"detail":"SKU ELE-1001 already exists","instance":"/api/products","errorCode":"DUPLICATE_SKU"}

Exercise: Add a handler so that a PaymentFailedException returns 402 Payment Required with errorCode PAYMENT_FAILED.

Show answer
@ExceptionHandler(PaymentFailedException.class)
public ProblemDetail paymentFailed(PaymentFailedException ex) {
    return problem(HttpStatus.PAYMENT_REQUIRED, "Payment failed", "PAYMENT_FAILED", ex.getMessage());
}