The full picture
- Repository/adapter layer: translate technology exceptions (SQL, HTTP client, Kafka) into a small infra hierarchy; attach identifiers, never raw SQL/PII.
- Domain layer: explicit business exceptions (
InsufficientStock,DuplicateBooking) extending a sealed-ish base; expected alternatives may be result types instead. - Application edge: one
@ControllerAdvice: business → 4xx withProblemDetailbody (type, title, correlationId), infra → 502/503 with retry hints, unknown → 500 generic (no internals leaked). Log level policy: 4xx = INFO/WARN, 5xx = ERROR with stack. - Async/batch edges (Kafka listener, scheduler) get the same 'single handler' treatment — DLQ + alert instead of HTTP mapping.
- Cross-cutting: correlation ID in MDC from the first filter; error metrics by exception type; alert on rate, not single occurrences.
@RestControllerAdvice
class ApiErrors {
@ExceptionHandler(DomainException.class)
ProblemDetail domain(DomainException e) {
var p = ProblemDetail.forStatusAndDetail(e.status(), e.getMessage());
p.setProperty("correlationId", MDC.get("cid"));
return p; // RFC 9457 response
}
@ExceptionHandler(Exception.class)
ProblemDetail unknown(Exception e) {
log.error("unhandled", e); // logged ONCE, here
return ProblemDetail.forStatus(500); // no internals leaked
}
}