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
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
- Map a
Throwableto an HTTP status and message. This is policy, and it belongs in one place. - 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
ApiExceptionunchanged. 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.