package com.example.api.error;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.Instant;
import java.util.List;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public record ApiError(
int status,
String code,
String message,
String path,
Instant timestamp,
List<FieldError> fieldErrors
) {
public ApiError {
if (fieldErrors == null) {
fieldErrors = List.of();
}
}
public record FieldError(String field, String reason) {
}
}
package com.example.api.error;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String resource, Object id) {
super(resource + " with id " + id + " was not found");
}
public ResourceNotFoundException(String message) {
super(message);
}
}
package com.example.api.error;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.Instant;
import java.util.List;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(ResourceNotFoundException ex, HttpServletRequest req) {
return build(HttpStatus.NOT_FOUND, "RESOURCE_NOT_FOUND", ex.getMessage(), req, List.of());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest req) {
List<ApiError.FieldError> fields = ex.getBindingResult().getFieldErrors().stream()
.map(this::toFieldError)
.collect(Collectors.toList());
return build(HttpStatus.BAD_REQUEST, "VALIDATION_FAILED", "Request validation failed", req, fields);
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiError> handleConstraintViolation(ConstraintViolationException ex, HttpServletRequest req) {
List<ApiError.FieldError> fields = ex.getConstraintViolations().stream()
.map(v -> new ApiError.FieldError(v.getPropertyPath().toString(), v.getMessage()))
.collect(Collectors.toList());
return build(HttpStatus.BAD_REQUEST, "VALIDATION_FAILED", "Request validation failed", req, fields);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleUnexpected(Exception ex, HttpServletRequest req) {
log.error("Unhandled exception on {} {}", req.getMethod(), req.getRequestURI(), ex);
return build(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_ERROR",
"An unexpected error occurred", req, List.of());
}
private ApiError.FieldError toFieldError(FieldError error) {
return new ApiError.FieldError(error.getField(), error.getDefaultMessage());
}
private ResponseEntity<ApiError> build(HttpStatus status, String code, String message,
HttpServletRequest req, List<ApiError.FieldError> fields) {
ApiError body = new ApiError(status.value(), code, message, req.getRequestURI(), Instant.now(), fields);
return ResponseEntity.status(status).body(body);
}
}
package com.example.api.user;
import com.example.api.error.ResourceNotFoundException;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserRepository users;
public UserController(UserRepository users) {
this.users = users;
}
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
return users.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", id));
}
@PostMapping
public ResponseEntity<User> create(@Valid @RequestBody CreateUserRequest request) {
User saved = users.save(new User(request.name(), request.email()));
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
public record CreateUserRequest(
@NotBlank(message = "name must not be blank") String name,
@Email(message = "email must be a valid address") String email
) {
}
}
This snippet shows how a Spring Boot API can return one predictable error shape for every failure, whether it comes from validation, a missing resource, or an unexpected bug. Centralizing this logic in a @RestControllerAdvice keeps controllers clean and guarantees clients never have to guess the format of an error body.
The ApiError response tab defines the immutable payload every error is serialized into. It carries a machine-readable code, a human message, the request path, an ISO timestamp, and an optional list of fieldErrors for validation failures. The nested FieldError record captures which field failed and why, so front-end forms can highlight inputs precisely. Because fieldErrors defaults to an empty list, non-validation errors serialize cleanly without a null field.
The ResourceNotFoundException tab is a small domain exception that extends RuntimeException. Modeling it as its own type lets the handler map it to a 404 and a stable RESOURCE_NOT_FOUND code, rather than leaking a generic 500. Service code throws it naturally when a lookup returns nothing.
The GlobalExceptionHandler tab is where the pattern lives. Each @ExceptionHandler method translates one exception category into an ApiError with the correct HttpStatus. handleNotFound returns 404; handleValidation intercepts Spring's MethodArgumentNotValidException, walks BindingResult, and builds a FieldError for every rejected value; handleConstraintViolation does the same for @Validated path and query params. The catch-all handleUnexpected logs the stack trace but returns a deliberately vague message, so internal details are never exposed to callers. A shared build helper assembles the response so status, code, and path are always populated the same way.
The UserController tab shows the advice in action: @Valid on the request body triggers the validation handler automatically, and the orElseThrow throwing ResourceNotFoundException routes into handleNotFound. Notice the controller contains zero try/catch blocks — that separation is the whole point.
A key trade-off is that broad advice can accidentally swallow errors, so ordering from specific to general matters, and the catch-all should stay last. This approach scales well: adding a new error type means adding one handler method, and every client keeps seeing the same JSON contract.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
package com.example.starter.config;
import com.example.starter.properties.CustomProperties;
import com.example.starter.service.CustomService;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
Custom Spring Boot starters
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
Share this code
Here's the card — post it anywhere.