Back to Articles

Spring Boot · Error Handling

The Error Handling Setup I Use in Every Production API

2026-09-039 min read
Spring BootJavaAPI Design

Every API eventually throws something it did not plan for: a missing record, a bad request body, a downstream call that times out. What separates a production API from a student project is not whether it fails, it is what the client gets back when it does.

The problem

A caller hits GET /api/products/123 for a product that does not exist. What should come back? A raw stack trace leaks package names and library versions. A generic 500 tells the client nothing about whether to retry. And if every controller handles its own errors, every controller ends up with a slightly different shape, which means every client integration ends up with a pile of special cases.

The naive solution

The first instinct is usually to let Spring do whatever it does by default, or to catch exceptions inline:

ProductController.java
1@RestController
2@RequestMapping("/api/products")
3public class ProductController {
4
5 @GetMapping("/{id}")
6 public Product getProduct(@PathVariable Long id) {
7 return productRepository.findById(id)
8 .orElseThrow(() -> new RuntimeException("Product not found"));
9 }
10}

This throws a RuntimeException, which Spring Boot turns into a 500 with a default error body containing a timestamp, a status, and not much else. The client cannot tell a missing product from a database outage. Worse, this pattern gets copied into every controller, and error handling becomes whatever each developer felt like writing that day.

Why it breaks

Once the API has more than a handful of endpoints, this shows up as:

  • Validation errors, business rule violations, and missing resources all returning the same generic 500.
  • Inconsistent JSON shapes between controllers, which breaks any shared error-handling code on the client.
  • Stack traces and internal class names reaching the client in non-production environments, and sometimes in production too.
  • No way to correlate a client-reported error with the corresponding log line, because nothing ties the response to a trace.

The production solution

The fix is one exception-handling layer that every controller shares, built on three pieces: a fixed response shape, a set of domain exceptions that map to that shape, and a single @RestControllerAdvice that catches everything.

First, the response contract. Every error, regardless of where it came from, comes back in this shape:

ApiError.java
1public record ApiError(
2 Instant timestamp,
3 int status,
4 String code,
5 String message,
6 String path,
7 String traceId
8) {
9 public static ApiError of(HttpStatus status, ErrorCode code,
10 String message, HttpServletRequest request) {
11 return new ApiError(
12 Instant.now(),
13 status.value(),
14 code.name(),
15 message,
16 request.getRequestURI(),
17 MDC.get("traceId")
18 );
19 }
20}

The code field is the important one. It is stable and machine-readable (PRODUCT_NOT_FOUND, VALIDATION_FAILED), so a frontend can branch on it without parsing the human-readable message. The traceId comes from an MDC value set earlier in the request (typically by a filter that reads or generates a X-Request-ID header), so a support ticket that includes the trace ID can be matched to an exact log line in seconds instead of guesswork.

Then the domain exceptions: ResourceNotFoundException, BusinessException, and Spring's own MethodArgumentNotValidException for bean validation. Each carries an ErrorCode enum value instead of a free-text string, which keeps the set of possible codes closed and documentable.

Finally, the handler that ties it together:

GlobalExceptionHandler.java
1@RestControllerAdvice
2public class GlobalExceptionHandler {
3
4 @ExceptionHandler(ResourceNotFoundException.class)
5 public ResponseEntity<ApiError> handleNotFound(
6 ResourceNotFoundException ex, HttpServletRequest request) {
7 ApiError error = ApiError.of(HttpStatus.NOT_FOUND,
8 ex.getErrorCode(), ex.getMessage(), request);
9 return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
10 }
11
12 @ExceptionHandler(BusinessException.class)
13 public ResponseEntity<ApiError> handleBusiness(
14 BusinessException ex, HttpServletRequest request) {
15 ApiError error = ApiError.of(HttpStatus.UNPROCESSABLE_ENTITY,
16 ex.getErrorCode(), ex.getMessage(), request);
17 return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(error);
18 }
19
20 @ExceptionHandler(MethodArgumentNotValidException.class)
21 public ResponseEntity<ApiError> handleValidation(
22 MethodArgumentNotValidException ex, HttpServletRequest request) {
23 String message = ex.getBindingResult().getFieldErrors().stream()
24 .map(f -> f.getField() + ": " + f.getDefaultMessage())
25 .collect(Collectors.joining(", "));
26 ApiError error = ApiError.of(HttpStatus.BAD_REQUEST,
27 ErrorCode.VALIDATION_FAILED, message, request);
28 return ResponseEntity.badRequest().body(error);
29 }
30
31 @ExceptionHandler(Exception.class)
32 public ResponseEntity<ApiError> handleUnexpected(
33 Exception ex, HttpServletRequest request) {
34 log.error("Unhandled exception on {}", request.getRequestURI(), ex);
35 ApiError error = ApiError.of(HttpStatus.INTERNAL_SERVER_ERROR,
36 ErrorCode.INTERNAL_ERROR, "Something went wrong on our end.", request);
37 return ResponseEntity.internalServerError().body(error);
38 }
39}

Every response, whatever triggered it, now looks like this:

404 response
1{
2 "timestamp": "2026-09-03T18:30:00Z",
3 "status": 404,
4 "code": "PRODUCT_NOT_FOUND",
5 "message": "Product not found",
6 "path": "/api/products/123",
7 "traceId": "01K7Q3G2ZP5NBM8"
8}

Making it reusable

The whole thing lives in one package (common/error) with no dependency on any specific domain, which means it drops into a new service unchanged: GlobalExceptionHandler, ApiError, ErrorCode, and the three exception types. Domain-specific exceptions in each service just extend BusinessException or ResourceNotFoundException and supply their own error code.

One rule worth keeping: the message field is for the client, and it should never contain anything you would not want a support ticket to quote back at you. Internal messages (connection strings, SQL, stack traces) go to the log via log.error() in the catch-all handler, tagged with the same trace ID, never into the response body.

Testing it

The handler is a plain Spring bean, so it tests the same way any controller does: a slice test with @WebMvcTest, a mocked service that throws the domain exception, and an assertion on both the HTTP status and the code field in the response body. The regression worth guarding against is not "does it return an error", it is "does it return the right status and code for this exception type", since that mapping is the entire point of the handler.

What I learned

The tradeoff is a small one: every new exception type needs a deliberate decision about which ErrorCode and HTTP status it maps to, instead of letting Spring guess. That is a feature, not friction. It forces the question "what should the client actually do when this happens" at the point where the exception is defined, not months later when a client team asks why two different 500s mean two different things.