💥 Fail Gracefully: Error Handling, Validation, and the Art of Useful Errors

Generic error responses tell your users nothing and your developers even less. This article introduces the CRAFT framework — sealed exception hierarchies, RFC 7807 ProblemDetail responses, multi-layer validation, and a GlobalExceptionHandler that turns failures into useful communication.

Error handling in Spring Boot — RFC 7807 responses and sealed exceptions

📚 Series Navigation:
Previous: Part 7 - Guarding the Gates
👉 You are here: Part 8 - Fail Gracefully
Next: Part 9 - 10,000 Threads and a Dream →


📋 Introduction

Errors are not bugs. They're communication. When a user sends an invalid latitude of 999.0, that's not a failure of your system — it's a conversation: "Hey, I sent something wrong. What should I fix?" When an external API is down, that's another conversation: "This isn't your fault, but here's what happened and when to try again."

The problem isn't that errors happen. It's that most APIs communicate them terribly. A generic {"error": "Internal Server Error"} tells you nothing. A stack trace dumped into JSON is worse — it exposes internals and still doesn't help. And the classic {"status": 500} without any detail is the API equivalent of a shrug emoji.

In this article, we'll explore how the Weather Microservice turns errors into structured, helpful, RFC-compliant responses. We'll see the sealed exception hierarchy, the 14-handler GlobalExceptionHandler, and the art of making errors useful. ☕


🎨 The CRAFT Framework: Five Pillars of Error Handling

Meet CRAFT — five principles for graceful error handling:

Letter Principle What It Means
C Categorized Exceptions Sealed hierarchy with typed exception categories
R RFC 7807 All errors follow the ProblemDetail standard
A All-layer Validation Errors caught at controller, service, and domain levels
F Fallback Handlers Every possible exception has a handler — no unhandled surprises
T Type-safe Properties Custom properties (category, violations) on error responses

🏗️ The Sealed Exception Hierarchy

Java's sealed classes (Java 17+) create a closed hierarchy — only specified subclasses can extend the base class:

public abstract sealed class WeatherServiceException extends RuntimeException
    permits LocationNotFoundException, WeatherDataNotFoundException, WeatherApiException {

  protected WeatherServiceException(String message) {
    super(message);
  }

  protected WeatherServiceException(String message, Throwable cause) {
    super(message, cause);
  }

  public abstract String getCategory();
}

Why Sealed?

The sealed keyword provides compile-time exhaustiveness checking. When you handle a WeatherServiceException, the compiler knows there are exactly three possible types:

// The compiler knows these are ALL possibilities
switch (exception) {
    case LocationNotFoundException e -> handleNotFound(e);
    case WeatherDataNotFoundException e -> handleNotFound(e);
    case WeatherApiException e -> handleApiError(e);
    // No default needed — the compiler knows this is exhaustive
}

Compare to a regular class hierarchy:

// Without sealed: anyone can add new subclasses
// The switch is never truly exhaustive
// You always need a default case

Three Exception Types, Three Concerns

Exception HTTP Status Category When
LocationNotFoundException 404 LOCATION Location ID doesn't exist in DB
WeatherDataNotFoundException 404 WEATHER_DATA No weather records for a location
WeatherApiException 503 EXTERNAL_API External API failure, timeout, error

Each exception has a getCategory() method that returns a string identifier. This category appears in API responses, helping clients programmatically distinguish between error types.


📋 The GlobalExceptionHandler: 14 Handlers for Every Error

The GlobalExceptionHandler is a @RestControllerAdvice class that catches every possible exception and transforms it into an RFC 7807 ProblemDetail response:

Handler 1-3: Domain Exceptions

@ExceptionHandler(LocationNotFoundException.class)
public ProblemDetail handleLocationNotFound(LocationNotFoundException ex) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.NOT_FOUND, ex.getMessage());
    problem.setType(URI.create(problemBaseUrl + "/location-not-found"));
    problem.setTitle("Location Not Found");
    problem.setProperty("category", ex.getCategory());
    problem.setProperty("timestamp", Instant.now());
    return problem;
}

@ExceptionHandler(WeatherApiException.class)
public ProblemDetail handleWeatherApiException(WeatherApiException ex) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.SERVICE_UNAVAILABLE,
        "Failed to fetch weather data from external API: " + ex.getMessage());
    problem.setType(URI.create(problemBaseUrl + "/weather-api-error"));
    problem.setTitle("Weather API Error");
    problem.setProperty("category", ex.getCategory());
    problem.setProperty("timestamp", Instant.now());
    return problem;
}

Example response:

{
  "type": "https://weatherspring.com/problems/location-not-found",
  "title": "Location Not Found",
  "status": 404,
  "detail": "Location not found with id: 42",
  "category": "LOCATION",
  "timestamp": "2024-01-15T14:30:00Z"
}

Handler 4: Rate Limit Exceeded (429)

@ExceptionHandler(RequestNotPermitted.class)
public ProblemDetail handleRateLimitExceeded(RequestNotPermitted ex) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.TOO_MANY_REQUESTS,
        "Rate limit exceeded. Please try again later.");
    problem.setType(URI.create(problemBaseUrl + "/rate-limit-exceeded"));
    problem.setTitle("Rate Limit Exceeded");
    problem.setProperty("timestamp", Instant.now());
    return problem;
}

This catches Resilience4j's RequestNotPermitted exception from the rate limiter (Part 5) and converts it to a proper 429 response.

Handler 5: Database Conflicts (409)

@ExceptionHandler(DataIntegrityViolationException.class)
public ProblemDetail handleDataIntegrityViolation(DataIntegrityViolationException ex) {
    String detail = "A database constraint was violated.";
    if (ex.getMessage() != null && ex.getMessage().contains("Unique index")) {
      detail = "This record already exists in the database.";
    }

    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.CONFLICT, detail);
    problem.setType(URI.create(problemBaseUrl + "/data-integrity-violation"));
    problem.setTitle("Data Integrity Violation");
    problem.setProperty("timestamp", Instant.now());
    return problem;
}

Notice the smart message extraction — if the constraint violation mentions "Unique index", the user gets a helpful "record already exists" message instead of a raw database error.

Handlers 6-9: Validation Exceptions

The Weather Microservice handles four distinct validation exception types:

// Handler 6: @Valid on @RequestBody
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidationException(MethodArgumentNotValidException ex) {
    Map<String, String> errors = new LinkedHashMap<>();
    ex.getBindingResult().getAllErrors().forEach(error -> {
        String fieldName = ((FieldError) error).getField();
        String message = error.getDefaultMessage();
        errors.put(fieldName, message);
    });

    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.BAD_REQUEST, "Validation failed for one or more fields");
    problem.setProperty("errors", errors);
    problem.setProperty("timestamp", Instant.now());
    return problem;
}

// Handler 7: @NotNull/@Min/@Max on method parameters
@ExceptionHandler(ConstraintViolationException.class)
public ProblemDetail handleConstraintViolation(ConstraintViolationException ex) {
    Map<String, String> violations = new LinkedHashMap<>();
    ex.getConstraintViolations().forEach(violation ->
        violations.put(violation.getPropertyPath().toString(), violation.getMessage()));

    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.BAD_REQUEST, "Constraint validation failed");
    problem.setProperty("violations", violations);
    return problem;
}

// Handler 8: Spring 6.1+ method validation
@ExceptionHandler(HandlerMethodValidationException.class)
public ProblemDetail handleHandlerMethodValidation(HandlerMethodValidationException ex) {
    Map<String, String> violations = new LinkedHashMap<>();
    ex.getAllValidationResults().forEach(result -> {
        String parameterName = result.getMethodParameter().getParameterName();
        result.getResolvableErrors().forEach(error ->
            violations.put(parameterName, error.getDefaultMessage()));
    });

    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.BAD_REQUEST, "Method validation failed");
    problem.setProperty("violations", violations);
    return problem;
}

// Handler 9: Generic Jakarta validation
@ExceptionHandler(ValidationException.class)
public ProblemDetail handleValidationException(ValidationException ex) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.BAD_REQUEST, ex.getMessage());
    return problem;
}

Example validation error response:

{
  "type": "https://weatherspring.com/problems/validation-error",
  "title": "Validation Error",
  "status": 400,
  "detail": "Validation failed for one or more fields",
  "errors": {
    "latitude": "Latitude must be between -90 and 90",
    "name": "Name must be between 2 and 100 characters"
  },
  "timestamp": "2024-01-15T14:30:00Z"
}

Handler 10: Type Mismatch

@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ProblemDetail handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
    String detail = String.format(
        "Invalid value '%s' for parameter '%s'. Expected type: %s",
        ex.getValue(), ex.getName(),
        ex.getRequiredType() != null ? ex.getRequiredType().getSimpleName() : "unknown");

    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.BAD_REQUEST, detail);
    problem.setProperty("parameter", ex.getName());
    problem.setProperty("value", ex.getValue());
    problem.setProperty("expectedType",
        ex.getRequiredType() != null ? ex.getRequiredType().getSimpleName() : "unknown");
    return problem;
}

When someone sends GET /api/weather/current/location/abc (where abc should be a number), this handler produces:

{
  "title": "Type Mismatch",
  "status": 400,
  "detail": "Invalid value 'abc' for parameter 'locationId'. Expected type: Long",
  "parameter": "locationId",
  "value": "abc",
  "expectedType": "Long"
}

Handler 11: The ServletException Unwrapper

@ExceptionHandler(ServletException.class)
public ProblemDetail handleServletException(ServletException ex) {
    Throwable rootCause = ex.getRootCause();

    if (rootCause instanceof HandlerMethodValidationException) {
        return handleHandlerMethodValidation((HandlerMethodValidationException) rootCause);
    } else if (rootCause instanceof ConstraintViolationException) {
        return handleConstraintViolation((ConstraintViolationException) rootCause);
    } else if (rootCause instanceof ValidationException) {
        return handleValidationException((ValidationException) rootCause);
    }

    // Generic servlet error
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.INTERNAL_SERVER_ERROR,
        "A servlet error occurred. Please try again later.");
    return problem;
}

🔥 Critical Insight: In Spring Boot 3.5+, validation exceptions on controller method parameters get wrapped in ServletException. Without this unwrapper, validation errors would return 500 instead of 400. This handler peels off the wrapper and delegates to the correct specific handler.

Handler 12-13: Sealed Hierarchy Fallback and Catch-All

// Catches any WeatherServiceException not matched by specific handlers
@ExceptionHandler(WeatherServiceException.class)
public ProblemDetail handleWeatherServiceException(WeatherServiceException ex) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage());
    problem.setProperty("category", ex.getCategory());
    return problem;
}

// The ultimate catch-all — nothing escapes
@ExceptionHandler(Exception.class)
public ProblemDetail handleGenericException(Exception ex) {
    log.error("Unexpected error occurred", ex);
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.INTERNAL_SERVER_ERROR,
        "An unexpected error occurred. Please try again later.");
    return problem;
}

The catch-all handler is the safety net. No matter what exception occurs, the API returns a structured RFC 7807 response instead of a raw stack trace. The generic message hides internal details from clients while the log.error records the full stack trace for debugging.


📐 RFC 7807: The ProblemDetail Standard

Every error response follows RFC 7807 ProblemDetail:

{
  "type": "https://weatherspring.com/problems/location-not-found",
  "title": "Location Not Found",
  "status": 404,
  "detail": "Location not found with id: 42",
  "instance": "/api/locations/42",
  "category": "LOCATION",
  "timestamp": "2024-01-15T14:30:00Z"
}
Field RFC 7807 Purpose
type Required URI identifying the error type (for documentation)
title Required Human-readable summary
status Required HTTP status code
detail Optional Specific explanation of this occurrence
instance Optional URI identifying this specific occurrence
Custom fields Extension category, timestamp, errors, violations

The type URI points to documentation about this error. Clients can use it for programmatic error handling:

if (error.type.endsWith('/rate-limit-exceeded')) {
    // Wait and retry
} else if (error.type.endsWith('/location-not-found')) {
    // Show "Location not found" message
}

Enabling RFC 7807 in Spring Boot

spring:
  mvc:
    problemdetails:
      enabled: true

This single setting tells Spring Boot to use ProblemDetail for its built-in error responses (404 Not Found, 405 Method Not Allowed, etc.). Combined with the GlobalExceptionHandler, every error in the application — whether thrown by your code, Spring MVC, or the servlet container — returns a consistent RFC 7807 response.


🔗 The Error Flow: From Exception to Response

1. Exception Thrown
   LocationNotFoundException("Location not found with id: 42")

2. Spring MVC catches exception, routes to @ExceptionHandler

3. GlobalExceptionHandler.handleLocationNotFound()
   +- log.warn("Location not found: {}", ex.getMessage())
   +- Create ProblemDetail(404, message)
   +- Set type URI, title, category, timestamp
   +- Return ProblemDetail

4. Spring MVC serializes to JSON
   +- Content-Type: application/problem+json

5. HTTP Response
   HTTP/1.1 404 Not Found
   Content-Type: application/problem+json
   {
     "type": ".../location-not-found",
     "title": "Location Not Found",
     "status": 404,
     "detail": "Location not found with id: 42",
     "category": "LOCATION",
     "timestamp": "2024-01-15T14:30:00Z"
   }

Notice the Content-Type: application/problem+json — this is the RFC 7807 media type that tells clients this is a structured error response, not a regular JSON body.


✅ Error Handling Checklist

  • [ ] Sealed exception hierarchy — Compile-time exhaustiveness for domain exceptions
  • [ ] RFC 7807 ProblemDetail for all error responses — problemdetails.enabled: true
  • [ ] Specific handlers first — LocationNotFoundException before generic Exception
  • [ ] Validation errors include field detailserrors map with field names and messages
  • [ ] Type mismatch includes expected type — Tells clients exactly what format to use
  • [ ] Rate limit returns 429 — Not 500, with a helpful retry message
  • [ ] Database conflicts return 409 — With smart message extraction
  • [ ] ServletException unwrapper — Handles Spring Boot 3.5+ validation wrapping
  • [ ] Catch-all handler — No exception ever returns a raw stack trace
  • [ ] Generic error hides internals — "An unexpected error occurred" in the response, full stack trace in logs
  • [ ] Custom propertiescategory, timestamp, violations for programmatic error handling

🎓 Conclusion: Errors Are Communication

Errors are the most underrated part of an API. They're the first thing a developer sees when something goes wrong, and the quality of those error messages determines whether they fix the problem in 5 minutes or 5 hours:

  1. The CRAFT framework (Categorized exceptions, RFC 7807, All-layer validation, Fallback handlers, Type-safe properties) guides error handling design
  2. Sealed exception hierarchies (Java 17+) provide compile-time guarantees that all exception types are handled
  3. RFC 7807 ProblemDetail standardizes error responses with type, title, status, and detail fields
  4. 14 exception handlers cover every error scenario — from validation failures to rate limits to database conflicts
  5. Validation errors include field-level details so clients know exactly what to fix
  6. The ServletException unwrapper handles Spring Boot 3.5+'s validation exception wrapping
  7. Custom properties (category, violations, expectedType) enable programmatic error handling by clients
  8. The catch-all handler ensures no exception ever results in a raw stack trace

Errors are not failures — they're conversations between your API and its consumers. Make those conversations clear, structured, and helpful.

Coming Next Week:
Part 9: 10,000 Threads and a Dream - Virtual Threads and Concurrent Microservices 🚀


📚 Series Progress

✅ Part 1: The Blueprint Before the Build
✅ Part 2: Spring Boot Alchemy
✅ Part 3: REST Assured
✅ Part 4: The Data Foundation
✅ Part 5: When the World Breaks
✅ Part 6: Cache Me If You Can
✅ Part 7: Guarding the Gates
✅ Part 8: Fail Gracefully ← You just finished this!
⬜ Part 9: 10,000 Threads and a Dream
⬜ Part 10: Can You See Me Now?
⬜ Part 11: Trust, But Verify
⬜ Part 12: Ship It
⬜ Part 13: To Production and Beyond


Happy coding, and remember — the best error message is the one that tells you exactly what went wrong and how to fix it.


Robert Marcel Saveanu

Robert Marcel Saveanu

Software engineer with 15 years in testing, architecture, and the art of surviving corporate dysfunction. Writing about code, quality, and the humans behind both.

Great! You’ve successfully signed up.

Welcome back! You've successfully signed in.

You've successfully subscribed to Codyssey.

Success! Check your email for magic link to sign-in.

Success! Your billing info has been updated.

Your billing was not updated.