Error Handling Patterns

A domain exception hierarchy, RFC 9457 ProblemDetail responses instead of a hand-rolled error shape, exception translation at architectural boundaries, and the anti-patterns that quietly hide real production failures

← Back to Index

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));
}
Don't use Optional for fields, method parameters, or collections

Fields: 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" }]
}
Why this matters more than it looks: a hand-rolled error shape is a hidden API contract nobody versioned

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 ProblemDetail from 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 ProblemDetail already 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

๐ŸŽ“ Junior level

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.

๐Ÿ”ฅ Senior level

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.