CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/springboot-error-handling

Error handling for Spring Boot APIs — @ControllerAdvice, structured error

84

1.76x
Quality

75%

Does it follow best practices?

Impact

99%

1.76x

Average score across 5 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

SKILL.mdskills/springboot-error-handling/

name:
springboot-error-handling
description:
Production error handling that belongs in every Spring Boot REST API from day one. Apply whenever building, extending, or scaffolding a Spring Boot backend -- even if the task only asks for endpoints or CRUD operations. These patterns prevent stack trace leaks, inconsistent error formats, silent swallowed exceptions, and unstructured validation failures.
keywords:
spring boot, spring boot api, rest api, spring mvc, spring controller, spring service, spring boot crud, spring boot rest, spring boot backend, spring boot application, spring boot endpoint, spring boot server, spring boot project, java api, java rest api, java backend, java server, kotlin spring, spring boot scaffold, spring boot setup, spring boot create, build api, new api, api endpoint, controller, restcontroller
license:
MIT

Spring Boot Error Handling

Production-grade error handling that belongs in every Spring Boot REST API from day one. When building any Spring Boot application -- whether a quick prototype or a production service -- always include these patterns without being asked.


Why This Matters From Day One

Error handling is not a "harden later" task. Without it from the start:

  • Clients get stack traces -- Spring Boot's default error response includes the full Java stack trace unless explicitly configured otherwise. One unhandled exception and internal class names, line numbers, and dependency versions leak to every caller.
  • Every controller invents its own format -- One endpoint returns { "error": "..." }, another returns { "message": "..." }, a third returns Spring's default Whitelabel error page. Clients cannot parse errors reliably.
  • Validation errors are unusable -- Without a handler for MethodArgumentNotValidException, Spring returns a 400 with a massive JSON blob that clients cannot meaningfully display.
  • Database errors leak schema details -- DataIntegrityViolationException exposes table names, column names, and constraint names in its raw message.
  • No correlation for debugging -- Without MDC context, log entries for a failed request cannot be traced back to the specific request that caused them.

The Patterns -- Always Apply These

1. Custom Exception Hierarchy

RIGHT -- Define typed exceptions with HTTP status mapping:

// src/main/java/com/example/exception/AppException.java
public abstract class AppException extends RuntimeException {
    private final HttpStatus status;
    private final String code;

    protected AppException(HttpStatus status, String code, String message) {
        super(message);
        this.status = status;
        this.code = code;
    }

    public HttpStatus getStatus() { return status; }
    public String getCode() { return code; }
}
public class ResourceNotFoundException extends AppException {
    public ResourceNotFoundException(String resource, Object id) {
        super(HttpStatus.NOT_FOUND, "NOT_FOUND", resource + " with id " + id + " not found");
    }
}

public class BusinessRuleException extends AppException {
    public BusinessRuleException(String message) {
        super(HttpStatus.UNPROCESSABLE_ENTITY, "BUSINESS_RULE_VIOLATION", message);
    }
}

public class DuplicateResourceException extends AppException {
    public DuplicateResourceException(String message) {
        super(HttpStatus.CONFLICT, "DUPLICATE", message);
    }
}

WRONG -- Throwing generic RuntimeException or using ResponseStatusException without a consistent error format:

// BAD: Inconsistent, no structured body, different format per endpoint
throw new RuntimeException("Order not found");
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Order not found");

2. Global Exception Handler with RestControllerAdvice

RIGHT -- A single centralized handler that catches everything and returns a consistent format:

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // Handle custom application exceptions
    @ExceptionHandler(AppException.class)
    public ResponseEntity<Map<String, Object>> handleAppException(AppException ex, HttpServletRequest request) {
        log.warn("Application error: {} {} -> {} {}", request.getMethod(), request.getRequestURI(), ex.getCode(), ex.getMessage());
        return ResponseEntity.status(ex.getStatus())
            .body(Map.of("error", Map.of(
                "code", ex.getCode(),
                "message", ex.getMessage()
            )));
    }

    // Handle @Valid validation failures
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
        List<Map<String, String>> details = ex.getBindingResult().getFieldErrors().stream()
            .map(e -> Map.of("field", e.getField(), "message", Objects.requireNonNullElse(e.getDefaultMessage(), "invalid")))
            .toList();
        return ResponseEntity.badRequest()
            .body(Map.of("error", Map.of(
                "code", "VALIDATION_ERROR",
                "message", "Invalid request",
                "details", details
            )));
    }

    // Handle type mismatches in path variables / request params
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ResponseEntity<Map<String, Object>> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
        String message = "Parameter '" + ex.getName() + "' must be of type " + ex.getRequiredType().getSimpleName();
        return ResponseEntity.badRequest()
            .body(Map.of("error", Map.of("code", "INVALID_PARAMETER", "message", message)));
    }

    // Handle malformed JSON
    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<Map<String, Object>> handleUnreadable(HttpMessageNotReadableException ex) {
        return ResponseEntity.badRequest()
            .body(Map.of("error", Map.of("code", "MALFORMED_REQUEST", "message", "Request body is malformed or missing")));
    }

    // Handle database constraint violations (e.g., unique constraints)
    @ExceptionHandler(DataIntegrityViolationException.class)
    public ResponseEntity<Map<String, Object>> handleDataIntegrity(DataIntegrityViolationException ex) {
        log.error("Data integrity violation", ex);
        return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(Map.of("error", Map.of("code", "CONFLICT", "message", "Operation violates a data constraint")));
    }

    // Catch-all for unexpected exceptions -- never leak internals
    @ExceptionHandler(Exception.class)
    public ResponseEntity<Map<String, Object>> handleGeneric(Exception ex, HttpServletRequest request) {
        log.error("Unhandled exception at {} {}", request.getMethod(), request.getRequestURI(), ex);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(Map.of("error", Map.of("code", "INTERNAL_ERROR", "message", "An unexpected error occurred")));
    }
}

WRONG -- Per-controller try/catch blocks that duplicate error handling logic:

// BAD: Error handling scattered across every controller method
@GetMapping("/api/orders/{id}")
public ResponseEntity<?> getOrder(@PathVariable Long id) {
    try {
        Order order = orderService.findById(id);
        return ResponseEntity.ok(order);
    } catch (OrderNotFoundException e) {
        return ResponseEntity.status(404).body(Map.of("message", e.getMessage()));
    } catch (Exception e) {
        return ResponseEntity.status(500).body(Map.of("error", e.getMessage())); // leaks internals
    }
}

3. Disable Stack Traces in Responses

RIGHT -- Configure Spring to never include stack traces or internal details in error responses:

# application.properties
server.error.include-stacktrace=never
server.error.include-message=never
server.error.include-binding-errors=never
server.error.include-exception=false

WRONG -- Leaving defaults or explicitly enabling stack traces:

# BAD: This leaks stack traces to clients
server.error.include-stacktrace=always
server.error.include-message=always

4. Bean Validation with the Valid Annotation

RIGHT -- Use Jakarta Validation annotations on DTOs with the Valid annotation on controller parameters:

public record CreateOrderRequest(
    @NotBlank(message = "Customer name is required")
    String customerName,

    @NotEmpty(message = "Order must have at least one item")
    @Valid
    List<OrderItemRequest> items
) {}

public record OrderItemRequest(
    @NotNull(message = "Product ID is required")
    @Positive(message = "Product ID must be positive")
    Long productId,

    @Min(value = 1, message = "Quantity must be at least 1")
    @Max(value = 100, message = "Quantity cannot exceed 100")
    int quantity
) {}
@PostMapping("/api/orders")
@ResponseStatus(HttpStatus.CREATED)
public OrderResponse createOrder(@Valid @RequestBody CreateOrderRequest request) {
    return orderService.createOrder(request);
}

WRONG -- Manual validation in controllers instead of using the annotation framework:

// BAD: Reimplementing what @Valid + annotations already do
@PostMapping("/api/orders")
public ResponseEntity<?> createOrder(@RequestBody CreateOrderRequest request) {
    if (request.getCustomerName() == null || request.getCustomerName().isBlank()) {
        return ResponseEntity.badRequest().body("Customer name is required");
    }
    // ... more manual checks
}

5. Structured Logging with MDC Context

RIGHT -- Add request context via a filter so all log lines for a request are traceable:

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestContextFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                     FilterChain filterChain) throws ServletException, IOException {
        String requestId = Optional.ofNullable(request.getHeader("X-Request-Id"))
            .orElse(UUID.randomUUID().toString());
        MDC.put("requestId", requestId);
        response.setHeader("X-Request-Id", requestId);
        try {
            filterChain.doFilter(request, response);
        } finally {
            MDC.clear();
        }
    }
}

And reference it in your log pattern:

logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} [%X{requestId}] %-5level %logger{36} - %msg%n

WRONG -- Logging without any request correlation:

// BAD: No way to correlate this log entry to the request that caused it
log.error("Failed to process order", ex);

6. Clean Controllers -- Throw, Don't Catch

RIGHT -- Controllers throw exceptions and the global handler deals with responses:

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping("/{id}")
    public OrderResponse getOrder(@PathVariable Long id) {
        return orderService.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Order", id));
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public OrderResponse createOrder(@Valid @RequestBody CreateOrderRequest request) {
        return orderService.createOrder(request);
    }
}

Error Response Format

All errors must follow this shape:

{
  "error": {
    "code": "NOT_FOUND",
    "message": "Order with id 42 not found"
  }
}

For validation errors with field-level detail:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid request",
    "details": [
      { "field": "customerName", "message": "Customer name is required" },
      { "field": "items", "message": "Order must have at least one item" }
    ]
  }
}

Never:

  • Return raw exception messages from libraries or frameworks
  • Return stack traces in any environment
  • Return different error shapes from different controllers
  • Return errors as 200 responses
  • Expose database table names, column names, or constraint names

Checklist

Every Spring Boot REST API must have from the start:

  • A RestControllerAdvice class with ExceptionHandler methods
  • Custom exception hierarchy (AppException base + subtypes like ResourceNotFoundException, BusinessRuleException, DuplicateResourceException)
  • Handler for MethodArgumentNotValidException returning field-level validation details
  • Handler for DataIntegrityViolationException that hides constraint details
  • Handler for HttpMessageNotReadableException (malformed JSON)
  • Catch-all Exception handler that logs the full error and returns a generic message
  • Consistent error response format: { "error": { "code": "...", "message": "..." } }
  • No stack traces leaked: server.error.include-stacktrace=never
  • The Valid annotation on all RequestBody parameters with Jakarta Validation annotations on DTOs
  • MDC-based request context filter for log correlation
  • Appropriate HTTP status codes: 400 validation, 404 not found, 409 conflict/duplicate, 422 business rule, 500 internal

skills

springboot-error-handling

tile.json