← Java articles

Java notes

Error Modeling in a Java REST Service: Translate Exceptions Without Changing the Wire Contract

Typed vs untyped error details, and why "helpful" mapping can quietly break your clients

Shubham Anandani··~9 min read

The problem

  • Untyped: a raw JSON tree (JsonNode), whatever shape the producer chose.
  • Typed: a Java class or record bound to a fixed schema.

Principle: translate the status, preserve the details

  1. Map a Throwable to an HTTP status and message. This is policy, and it belongs in one place.
  2. Carry the error details through untouched. If the producer's contract is untyped, leave it untyped.

A small translator

import com.fasterxml.jackson.databind.JsonNode;

import java.util.NoSuchElementException;
import java.util.concurrent.TimeoutException;

public final class ApiException extends RuntimeException {
    private final int status;
    private final String code;
    private final JsonNode details;          // untyped on purpose

    public ApiException(int status, String message, String code, JsonNode details, Throwable cause) {
        super(message, cause);
        this.status = status;
        this.code = code;
        this.details = details;
    }

    public int status() { return status; }
    public String code() { return code; }
    public JsonNode details() { return details; }

    public static ApiException translate(Throwable t) {
        if (t instanceof ApiException existing) {
            return existing;                                   // never re-wrap
        }
        if (t instanceof IllegalArgumentException) {
            return new ApiException(400, t.getMessage(), "BAD_REQUEST", null, t);
        }
        if (t instanceof NoSuchElementException) {
            return new ApiException(404, t.getMessage(), "NOT_FOUND", null, t);
        }
        if (t instanceof TimeoutException) {
            return new ApiException(504, "Upstream timeout", "TIMEOUT", null, t);
        }
        return new ApiException(500, "Internal error", "INTERNAL", null, t);
    }
}
  • Return an existing ApiException unchanged. Wrapping it again discards its status and details.
  • Hide internals for 500s. The message is generic; the cause is kept for server logs.
  • Keep the mapping table small and tested. Every exception type you map is a promise to clients.

One place to build the response

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;

@Provider
public class ApiExceptionMapper implements ExceptionMapper<Throwable> {

    private static final ObjectMapper MAPPER = new ObjectMapper();

    @Override
    public Response toResponse(Throwable t) {
        ApiException e = ApiException.translate(t);
        return Response.status(e.status())
                .type(MediaType.APPLICATION_JSON)
                .entity(body(e).toString())
                .build();
    }

    static ObjectNode body(ApiException e) {
        ObjectNode node = MAPPER.createObjectNode();
        node.put("status", e.status());
        node.put("message", e.getMessage() == null ? "" : e.getMessage());
        node.put("code", e.code());
        if (e.details() != null) {
            node.set("errorDetails", e.details());             // passed through as-is
        }
        return node;
    }
}

Batch operations need extra care

public static <K> Map<K, ApiException> errorsByKey(Map<K, Throwable> failures) {
    Map<K, ApiException> result = new LinkedHashMap<>();
    failures.forEach((key, t) -> result.put(key, ApiException.translate(t)));
    return result;
}

Prove the wire does not change

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class ErrorDetailsWireTest {

    private final ObjectMapper mapper = new ObjectMapper();

    record ErrorInfo(String field, String reason) { }

    @Test
    void untypedDetailsSurviveUnchanged() throws Exception {
        JsonNode details = mapper.readTree(
                "{\"field\":\"email\",\"reason\":\"format\",\"custom\":\"kept\"}");

        ApiException e = new ApiException(422, "invalid", "VALIDATION", details, null);
        JsonNode body = mapper.readTree(ApiExceptionMapper.body(e).toString());

        assertEquals(details, body.get("errorDetails"));
    }

    @Test
    void typedConversionLosesUnknownFields() throws Exception {
        JsonNode details = mapper.readTree(
                "{\"field\":\"email\",\"reason\":\"format\",\"custom\":\"kept\"}");

        ObjectMapper lenient = new ObjectMapper()
                .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        JsonNode roundTripped = lenient.valueToTree(lenient.convertValue(details, ErrorInfo.class));

        assertNotEquals(details, roundTripped);               // "custom" is gone
    }
}

Status codes are contract too

Failure modes

  • Typed envelopes rejecting or dropping unknown fields.
  • Double-wrapping an exception that already has a status.
  • Whole-batch failure when only one key failed.
  • Leaking stack traces or internal messages in 500 responses.

Demo to build

Takeaways

  • Separate "which status" from "which details".
  • Do not convert untyped details to typed ones unless you are deliberately versioning the contract.
  • Add a wire-level test for error responses.