📚 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 details —
errorsmap 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 properties —
category,timestamp,violationsfor 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:
- The CRAFT framework (Categorized exceptions, RFC 7807, All-layer validation, Fallback handlers, Type-safe properties) guides error handling design
- Sealed exception hierarchies (Java 17+) provide compile-time guarantees that all exception types are handled
- RFC 7807 ProblemDetail standardizes error responses with
type,title,status, anddetailfields - 14 exception handlers cover every error scenario — from validation failures to rate limits to database conflicts
- Validation errors include field-level details so clients know exactly what to fix
- The ServletException unwrapper handles Spring Boot 3.5+'s validation exception wrapping
- Custom properties (
category,violations,expectedType) enable programmatic error handling by clients - 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. ☕