High-level strategy- Define a canonical error envelope used across services (stable fields: error code, user message, type, trace id, timestamp, links). Use machine-readable codes for clients and human-friendly messages for users. Never return stack traces or internal objects.- Centralize mapping: each service translates internal exceptions to a Small set of domain exceptions (ValidationError, NotFound, AuthError, Conflict, InternalError) and an Exception-to-HTTP mapping layer.- Observability: attach and propagate a correlation/trace id (from incoming request or generated), log structured events (JSON) with error metadata, emit metrics (error counts by code), and forward rich details to secure monitoring (Sentry/Datadog) but not to clients.- Security: whitelist fields returned; sanitize messages; rate-limit error responses if necessary.- Contract versioning: include an error schema version in envelope.Sample error envelope (JSON){ "error": { "code": "USER_NOT_FOUND", "status": 404, "message": "User not found", "type": "NotFoundError", "timestamp": "2025-11-22T14:32:00Z", "trace_id": "abcd-1234-ef56", "details": { "field": "userId" }, "links": { "docs": "https://api.example.com/docs/errors#USER_NOT_FOUND" } }}Idiomatic implementation (Java + Spring Boot)- Use a @ControllerAdvice global handler to map exceptions, extract trace id (from header X-Trace-Id or MDC), log structured JSON, and return ResponseEntity with envelope.- Keep mapping small: custom domain exceptions extend a base ApiException with code + status. Internals catch unexpected exceptions, log full context (with stack) to monitoring, and return a generic INTERNAL_ERROR to client.Example:java
// custom base
public abstract class ApiException extends RuntimeException {
private final String code;
private final HttpStatus status;
public ApiException(String code, HttpStatus status, String msg){ super(msg); this.code=code; this.status=status; }
// getters
}
// global handler
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ApiException.class)
public ResponseEntity<Map<String,Object>> handleApi(ApiException ex, HttpServletRequest req){
String traceId = Optional.ofNullable(req.getHeader("X-Trace-Id")).orElse(UUID.randomUUID().toString());
Map<String,Object> envelope = Map.of("error", Map.of(
"code", ex.getCode(),
"status", ex.getStatus().value(),
"message", ex.getMessage(),
"type", ex.getClass().getSimpleName(),
"timestamp", Instant.now().toString(),
"trace_id", traceId
));
// structured log (context + sanitized)
log.error("HandledApiException code={} traceId={}", ex.getCode(), traceId);
// increment metric: metrics.counter("errors", "code", ex.getCode()).increment();
return ResponseEntity.status(ex.getStatus()).body(envelope);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String,Object>> handleUnexpected(Exception ex, HttpServletRequest req){
String traceId = Optional.ofNullable(req.getHeader("X-Trace-Id")).orElse(UUID.randomUUID().toString());
// send full exception to monitoring (Sentry/Datadog) with traceId
monitoring.capture(ex, Map.of("trace_id", traceId));
Map<String,Object> envelope = Map.of("error", Map.of(
"code", "INTERNAL_ERROR",
"status", 500,
"message", "An unexpected error occurred",
"type", "InternalError",
"timestamp", Instant.now().toString(),
"trace_id", traceId
));
log.error("Unhandled exception traceId={}", traceId, ex);
return ResponseEntity.status(500).body(envelope);
}
}
Implementation notes- Put mapping logic into a small library used by all services to ensure consistent codes.- Use MDC to propagate trace_id across threads and async tasks.- Document error codes and semantics for clients; keep messages stable and localized.- Monitor error-rate by code and service; set alerts for spikes in INTERNAL_ERROR or authentication failures.