Error Handling โ Beyond "Wrap It in a Try/Catch"
Good error handling means three things happening consistently across an entire codebase: exceptions that tell the caller what actually went wrong (not just "something failed"), an API response shape that's the same for every error regardless of which controller threw it, and enough logged context to diagnose an incident without needing to reproduce it.
// BEFORE โ the client gets a raw stack trace, a different JSON shape
// from every controller, and the log line says nothing useful
$ curl -X POST /api/orders -d '{"customerId": 999}'
< 500 Internal Server Error
< java.lang.NullPointerException: Cannot invoke "Customer.hasOutstandingDebt()"
< at com.shop.order.OrderService.validateBusinessRules(OrderService.java:42)
< at com.shop.order.OrderService.createOrder(OrderService.java:18)
< ... 47 more
// AFTER โ a translated domain exception, a standard RFC 9457 shape,
// and a log line with the actual context
$ curl -X POST /api/orders -d '{"customerId": 999}'
< 404 Not Found
< Content-Type: application/problem+json
< {
< "type": "https://api.shop.com/errors/entity-not-found",
< "title": "Entity Not Found",
< "status": 404,
< "detail": "Customer not found with id: 999",
< "instance": "/api/orders"
< }
Exception Hierarchy Strategy
Organize exceptions in a hierarchy that reflects your domain, not the technology that happened to trigger the failure.
// Base exception for the application โ unchecked, so the rollback
// behavior covered in Transactions applies automatically by default
public class ApplicationException extends RuntimeException {
private final ErrorCode errorCode;
public ApplicationException(ErrorCode code, String message) {
super(message);
this.errorCode = code;
}
public ApplicationException(ErrorCode code, String message, Throwable cause) {
super(message, cause);
this.errorCode = code;
}
public ErrorCode getErrorCode() { return errorCode; }
}
public class EntityNotFoundException extends ApplicationException {
public EntityNotFoundException(String entityType, Object id) {
super(ErrorCode.NOT_FOUND,
String.format("%s not found with id: %s", entityType, id));
}
}
public class BusinessRuleException extends ApplicationException {
public BusinessRuleException(String message) {
super(ErrorCode.BUSINESS_RULE_VIOLATION, message);
}
}
Error Codes โ mapped to both HTTP status and a ProblemDetail type URI
public enum ErrorCode {
NOT_FOUND("entity-not-found", HttpStatus.NOT_FOUND),
VALIDATION_FAILED("validation-failed", HttpStatus.BAD_REQUEST),
BUSINESS_RULE_VIOLATION("business-rule-violation", HttpStatus.UNPROCESSABLE_ENTITY),
INTERNAL_ERROR("internal-error", HttpStatus.INTERNAL_SERVER_ERROR);
private final String slug;
private final HttpStatus httpStatus;
ErrorCode(String slug, HttpStatus httpStatus) {
this.slug = slug;
this.httpStatus = httpStatus;
}
public URI typeUri() {
return URI.create("https://api.shop.com/errors/" + slug);
}
public HttpStatus httpStatus() { return httpStatus; }
}
Try-with-Resources
public List<String> readLines(Path path) throws IOException {
try (BufferedReader reader = Files.newBufferedReader(path)) {
return reader.lines().toList();
} // closed automatically, even on exception
}
// Custom AutoCloseable โ never rethrow from close() itself
public class PooledConnection implements AutoCloseable {
@Override
public void close() {
try {
connection.close();
} catch (SQLException e) {
log.warn("Failed to close connection", e); // log, don't rethrow here
}
}
}
Fail-Fast Principle
public class OrderService {
public Order createOrder(OrderRequest request) {
validateRequest(request); // structural validation first
validateBusinessRules(request); // then business rules
return processOrder(request); // only reached once both have passed
}
private void validateRequest(OrderRequest request) {
Objects.requireNonNull(request, "Request cannot be null");
if (request.getItems().isEmpty()) {
throw new ValidationException("Order must have at least one item");
}
}
private void validateBusinessRules(OrderRequest request) {
Customer customer = customerRepository.findById(request.getCustomerId())
.orElseThrow(() -> new EntityNotFoundException("Customer", request.getCustomerId()));
if (customer.hasOutstandingDebt()) {
throw new BusinessRuleException("Cannot create order: customer has outstanding debt");
}
}
}
Optional for Nullable Returns
// GOOD โ Optional makes "might not exist" part of the return type
public Optional<Customer> findByEmail(String email) {
return Optional.ofNullable(customerMap.get(email));
}
public CustomerDto getCustomerProfile(String email) {
return customerRepository.findByEmail(email)
.map(this::toDto)
.orElseThrow(() -> new EntityNotFoundException("Customer", email));
}
Optional for fields, method parameters, or collectionsFields: use null and document nullability
instead โ Optional isn't serializable and adds
overhead with no benefit as a stored field. Parameters:
overload the method or accept null directly โ
forcing a caller to wrap every argument in
Optional.of() just to call your method is
needless ceremony. Collections: return an empty collection,
never Optional<List<T>> โ an empty list
already means "nothing here" without an extra wrapper.
Global Exception Handling โ RFC 9457 ProblemDetail
Every error response from this site's Spring examples uses
ProblemDetail โ the standard, RFC 9457-compliant
error shape built into Spring since Spring 6 / Boot 3 โ rather
than a hand-rolled error record. A custom ErrorResponse
class means every client integrating with the API has to learn
your team's specific, one-off error shape; ProblemDetail
is a shape any HTTP client library already knows how to parse.
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(EntityNotFoundException.class)
public ProblemDetail handleNotFound(EntityNotFoundException ex) {
log.debug("Entity not found: {}", ex.getMessage());
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
ex.getErrorCode().httpStatus(), ex.getMessage());
problem.setType(ex.getErrorCode().typeUri());
problem.setTitle("Entity Not Found");
return problem;
}
@ExceptionHandler(ValidationException.class)
public ProblemDetail handleValidation(ValidationException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
ex.getErrorCode().httpStatus(), ex.getMessage());
problem.setType(ex.getErrorCode().typeUri());
problem.setTitle("Validation Failed");
problem.setProperty("fieldErrors", ex.getFieldErrors()); // RFC 9457 extension member
return problem;
}
@ExceptionHandler(Exception.class)
public ProblemDetail handleGeneric(Exception ex) {
log.error("Unexpected error", ex); // full stack trace, server-side only
// Never expose ex.getMessage() here โ it can leak internal details.
// The client gets a generic, safe detail string regardless of cause.
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred");
problem.setType(ErrorCode.INTERNAL_ERROR.typeUri());
return problem;
}
}
// Resulting response body โ standard RFC 9457 fields (type, title, status,
// detail, instance) plus a custom "fieldErrors" extension member
{
"type": "https://api.shop.com/errors/validation-failed",
"title": "Validation Failed",
"status": 400,
"detail": "Validation failed",
"instance": "/api/orders",
"fieldErrors": [{ "field": "items", "message": "must not be empty" }]
}
A custom ErrorResponse record is effectively
part of your public API surface the moment any client
parses it โ renaming a field breaks every integration
silently, with no standard to fall back on.
ProblemDetail is a registered IANA media type
(application/problem+json) with a stable,
documented shape; deviating from it means reinventing
something already standardized, maintained by nobody but
your own team, forever.
Logging at the Right Level for Each Failure
public class PaymentService {
private static final Logger log = LoggerFactory.getLogger(PaymentService.class);
public void processPayment(Payment payment) {
try {
gateway.process(payment);
} catch (GatewayTimeoutException e) {
// Recoverable โ WARN, not ERROR; and prefer @Retryable over a
// hand-rolled RetryableException where the retry policy is simple
log.warn("Payment gateway timeout, id={}", payment.getId());
throw new RetryableException(e);
} catch (PaymentDeclinedException e) {
// An expected business outcome, not a system failure โ INFO, not ERROR
log.info("Payment declined: id={}, reason={}", payment.getId(), e.getReason());
throw e;
} catch (Exception e) {
// Genuinely unexpected โ ERROR, with the full stack trace
log.error("Unexpected error processing payment: id={}", payment.getId(), e);
throw new PaymentException("Failed to process payment", e);
}
}
}
See Logging Frameworks for the full level guidance and structured/JSON logging conventions โ the short version here is that the severity of the log entry should match whether the failure is expected business behavior, a recoverable condition, or a genuine bug, not just "an exception was thrown."
Exception Translation at Architectural Boundaries
public class CustomerRepository {
public Customer save(Customer customer) {
try {
return jpaRepository.save(customer);
} catch (DataIntegrityViolationException e) {
// Translate a low-level, framework-specific exception into a
// domain exception the service layer actually understands
throw new CustomerAlreadyExistsException(customer.getEmail(), e);
}
}
}
public class CustomerAlreadyExistsException extends ApplicationException {
public CustomerAlreadyExistsException(String email, Throwable cause) {
super(ErrorCode.VALIDATION_FAILED,
"Customer already exists with email: " + email,
cause); // preserve the cause chain โ never discard it
}
}
This is what keeps OrderService from ever needing
to know that persistence happens to be JPA, or that a
duplicate key throws DataIntegrityViolationException
specifically โ the exact same decoupling the Dependency
Inversion Principle argues for at the class level, applied to
exception types. See
SOLID Principles.
Anti-Patterns to Avoid
// BAD โ empty catch block, silently swallows the failure
try {
riskyOperation();
} catch (Exception e) {
// nothing โ this is exactly the "commits with corrupted state"
// trap covered in Transactions
}
// BAD โ catching Throwable catches OutOfMemoryError, StackOverflowError too
try {
operation();
} catch (Throwable t) {
// you almost never want to intercept a JVM-level Error
}
// BAD โ exceptions used for ordinary, expected flow control
try {
int value = Integer.parseInt(input);
} catch (NumberFormatException e) {
value = 0;
}
// GOOD โ validate first; parsing a value you already know is numeric
// never needs an exception path at all
value = input.matches("\\d+") ? Integer.parseInt(input) : 0;
// BAD โ log and rethrow the same exception; the caller logs it again too
try {
operation();
} catch (Exception e) {
log.error("Error", e);
throw e; // duplicate log entries for one failure, every time this is called
}
// BAD โ a bare, generic exception with no type to catch specifically
throw new Exception("Something went wrong");
// GOOD โ a specific, catchable, self-documenting exception type
throw new InvalidOrderStateException(order.getId(), order.getStatus());
Best Practices and Common Pitfalls
โ Do
- Return
ProblemDetailfrom every@ExceptionHandlerโ it's a standard, IANA-registered shape, not a team-specific invention - Translate infrastructure exceptions into domain exceptions at repository/service boundaries โ the caller shouldn't need to know JPA threw a specific exception type
- Preserve the cause chain on every wrapped exception โ losing it turns a five-second root-cause lookup into guesswork
- Match log severity to what the failure actually means: expected business outcome (INFO), recoverable (WARN), genuine bug (ERROR)
- Validate structurally-invalid input before attempting an operation that would otherwise fail via exception
โ Don't
- Don't invent a custom error response shape when
ProblemDetailalready covers it โ see Section 5 - Don't swallow an exception in an empty catch block โ at minimum log it, and consider whether the transaction needs to roll back (see Transactions)
- Don't catch
Throwableโ it intercepts JVM-level errors you almost never want to handle - Don't expose a raw exception message from an unexpected failure to the client โ log the detail server-side, return a generic message
- Don't log an exception and then rethrow it unchanged โ log once, at the boundary that actually decides how to respond to it
Interview Questions
Q: Why is a custom ErrorResponse class generally worse than Spring's ProblemDetail?
ProblemDetail implements RFC 9457, a standard,
documented error response shape that any HTTP client already
knows how to parse. A custom error class is a one-off contract
every consumer of your API has to learn and maintain
compatibility with specifically, with no external standard to
fall back on.
Q: Why shouldn't you catch Throwable?
Throwable is the superclass of both
Exception and Error.
Error subtypes like OutOfMemoryError
or StackOverflowError represent conditions the
application generally cannot and should not try to recover
from โ catching them can mask a JVM in a genuinely broken
state instead of letting it fail visibly.
Q: What's wrong with logging an exception and then rethrowing it unchanged?
If the caller also logs the exception it receives (which is
common, especially at a top-level handler), the same single
failure gets logged twice, cluttering the logs and making it
harder to tell how many distinct failures actually
occurred.
Q: Why does exception translation at the repository layer matter architecturally, beyond just "cleaner code"?
Without translation, a service class that calls a JPA
repository ends up catching DataIntegrityViolationException
or similar Spring Data-specific types directly, which means the
service layer's code is now coupled to the specific
persistence technology's exception hierarchy. Swapping the
persistence mechanism, or even just upgrading a library that
changes its exception types, now requires changes to every
service that catches those specific types. Translating at the
repository boundary into a stable, domain-owned exception
(CustomerAlreadyExistsException) means the service
layer depends only on an abstraction the domain controls โ the
exact same principle Dependency Inversion applies to class
dependencies, applied here to the exception types that cross a
layer boundary.
Q: A generic exception handler catches Exception and returns ex.getMessage() directly in the response body. What's the concrete risk, separate from it just looking unprofessional?
An unexpected exception's message can contain information
never intended for a client โ a SQL exception's message
frequently includes table and column names or fragments of the
query itself; a file-not-found exception can reveal internal
filesystem paths; a downstream service's error message might
leak details about internal network topology or an internal
API contract. None of this is deliberately chosen by the
developer โ it leaks by construction, because
ex.getMessage() is whatever the underlying library
happened to generate for a completely different, internal
audience. The correct pattern is exactly what Section 5's
generic handler does: log the real exception and its message
server-side at ERROR level for diagnosis, and return a fixed,
generic detail string to the client that reveals
nothing about the actual cause.
Q: Your team's ApplicationException hierarchy extends RuntimeException. Explain why this specific choice matters beyond convenience, connecting it to transactional behavior.
As covered in Transactions,
@Transactional (and EJB's CMT) rolls back
automatically by default only on unchecked exceptions โ
checked exceptions require an explicit
rollbackOn declaration, and the transaction
commits by default if that declaration is missing, even though
an exception was thrown. Building the entire domain exception
hierarchy on RuntimeException means every
business-rule failure โ a customer with outstanding debt, an
order that can't be found โ automatically triggers a rollback
without requiring every single
@Transactional method in the codebase to
remember to declare rollbackOn for that specific
exception type. This isn't a minor convenience: choosing
checked exceptions for the same hierarchy would silently
reintroduce the exact commit-with-partial-writes trap that
Transactions identifies as the most common silent bug in
transactional Jakarta EE and Spring code.