
Error handling is a fundamental part of building resilient and user-friendly APIs. Instead of repeating try-catch blocks across your controllers and services, Spring Boot provides a clean way to handle exceptions globally using @ControllerAdvice and @ExceptionHandler. This approach centralizes error handling, ensures consistent API responses, and keeps your business logic clean.
In this post, you’ll learn how to handle exceptions globally in Spring Boot, create a comprehensive exception hierarchy, implement RFC 7807 Problem Details, add internationalization support, and apply production-ready best practices.
Why Global Exception Handling Matters
Without proper exception handling:
- Users receive generic or confusing error messages
- You leak technical details (stack traces) in production
- There’s duplicated try-catch code everywhere
- HTTP responses aren’t consistent or helpful
- Debugging becomes difficult without proper error context
- API consumers can’t programmatically handle errors
Spring Boot’s approach enables centralized error handling, helping you keep code clean and maintainable while providing excellent developer and user experience.
Project Setup
Include these dependencies in your pom.xml:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- For RFC 7807 Problem Details -->
<dependency>
<groupId>org.zalando</groupId>
<artifactId>problem-spring-web</artifactId>
<version>0.29.1</version>
</dependency>
</dependencies>
Step 1: Create Exception Hierarchy
Build a comprehensive exception hierarchy that covers all your API error cases:
// Base exception for all application exceptions
public abstract class ApplicationException extends RuntimeException {
private final String errorCode;
private final HttpStatus httpStatus;
private final Map<String, Object> details;
protected ApplicationException(
String message,
String errorCode,
HttpStatus httpStatus,
Map<String, Object> details) {
super(message);
this.errorCode = errorCode;
this.httpStatus = httpStatus;
this.details = details != null ? details : Map.of();
}
protected ApplicationException(
String message,
String errorCode,
HttpStatus httpStatus) {
this(message, errorCode, httpStatus, null);
}
public String getErrorCode() {
return errorCode;
}
public HttpStatus getHttpStatus() {
return httpStatus;
}
public Map<String, Object> getDetails() {
return details;
}
}
// 404 - Resource not found
public class ResourceNotFoundException extends ApplicationException {
public ResourceNotFoundException(String resourceType, String identifier) {
super(
String.format("%s with identifier '%s' not found", resourceType, identifier),
"RESOURCE_NOT_FOUND",
HttpStatus.NOT_FOUND,
Map.of(
"resourceType", resourceType,
"identifier", identifier
)
);
}
public ResourceNotFoundException(String message) {
super(message, "RESOURCE_NOT_FOUND", HttpStatus.NOT_FOUND);
}
}
// 400 - Bad request
public class BadRequestException extends ApplicationException {
public BadRequestException(String message) {
super(message, "BAD_REQUEST", HttpStatus.BAD_REQUEST);
}
public BadRequestException(String message, Map<String, Object> details) {
super(message, "BAD_REQUEST", HttpStatus.BAD_REQUEST, details);
}
}
// 409 - Conflict
public class ConflictException extends ApplicationException {
public ConflictException(String message) {
super(message, "CONFLICT", HttpStatus.CONFLICT);
}
public ConflictException(String resourceType, String field, String value) {
super(
String.format("%s with %s '%s' already exists", resourceType, field, value),
"RESOURCE_ALREADY_EXISTS",
HttpStatus.CONFLICT,
Map.of(
"resourceType", resourceType,
"field", field,
"value", value
)
);
}
}
// 401 - Unauthorized
public class UnauthorizedException extends ApplicationException {
public UnauthorizedException(String message) {
super(message, "UNAUTHORIZED", HttpStatus.UNAUTHORIZED);
}
}
// 403 - Forbidden
public class ForbiddenException extends ApplicationException {
public ForbiddenException(String message) {
super(message, "FORBIDDEN", HttpStatus.FORBIDDEN);
}
public ForbiddenException(String action, String resource) {
super(
String.format("You don't have permission to %s this %s", action, resource),
"FORBIDDEN",
HttpStatus.FORBIDDEN,
Map.of("action", action, "resource", resource)
);
}
}
// 422 - Unprocessable Entity (business logic errors)
public class BusinessValidationException extends ApplicationException {
public BusinessValidationException(String message, String errorCode) {
super(message, errorCode, HttpStatus.UNPROCESSABLE_ENTITY);
}
public BusinessValidationException(String message, String errorCode, Map<String, Object> details) {
super(message, errorCode, HttpStatus.UNPROCESSABLE_ENTITY, details);
}
}
Step 2: Create Error Response Models
Create structured error responses following RFC 7807 Problem Details:
import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ApiError(
String type,
String title,
int status,
String detail,
String instance,
String errorCode,
Instant timestamp,
String traceId,
Map<String, Object> details,
List<FieldError> fieldErrors
) {
public static Builder builder() {
return new Builder();
}
public static class Builder {
private String type = "about:blank";
private String title;
private int status;
private String detail;
private String instance;
private String errorCode;
private Instant timestamp = Instant.now();
private String traceId;
private Map<String, Object> details;
private List<FieldError> fieldErrors;
public Builder type(String type) {
this.type = type;
return this;
}
public Builder title(String title) {
this.title = title;
return this;
}
public Builder status(int status) {
this.status = status;
return this;
}
public Builder status(HttpStatus status) {
this.status = status.value();
this.title = status.getReasonPhrase();
return this;
}
public Builder detail(String detail) {
this.detail = detail;
return this;
}
public Builder instance(String instance) {
this.instance = instance;
return this;
}
public Builder errorCode(String errorCode) {
this.errorCode = errorCode;
return this;
}
public Builder traceId(String traceId) {
this.traceId = traceId;
return this;
}
public Builder details(Map<String, Object> details) {
this.details = details;
return this;
}
public Builder fieldErrors(List<FieldError> fieldErrors) {
this.fieldErrors = fieldErrors;
return this;
}
public ApiError build() {
return new ApiError(
type, title, status, detail, instance,
errorCode, timestamp, traceId, details, fieldErrors
);
}
}
}
public record FieldError(
String field,
Object rejectedValue,
String message
) {}
Step 3: Create Global Exception Handler
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import jakarta.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.UUID;
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
// Handle all application exceptions
@ExceptionHandler(ApplicationException.class)
public ResponseEntity<ApiError> handleApplicationException(
ApplicationException ex,
HttpServletRequest request) {
String traceId = generateTraceId();
log.warn("Application exception [traceId={}]: {} - {}",
traceId, ex.getErrorCode(), ex.getMessage());
ApiError error = ApiError.builder()
.status(ex.getHttpStatus())
.detail(ex.getMessage())
.errorCode(ex.getErrorCode())
.instance(request.getRequestURI())
.traceId(traceId)
.details(ex.getDetails())
.build();
return ResponseEntity.status(ex.getHttpStatus()).body(error);
}
// Handle validation errors
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
String traceId = generateTraceId();
List<FieldError> fieldErrors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> new FieldError(
error.getField(),
error.getRejectedValue(),
error.getDefaultMessage()
))
.toList();
log.warn("Validation failed [traceId={}]: {} field errors",
traceId, fieldErrors.size());
ApiError error = ApiError.builder()
.status(HttpStatus.BAD_REQUEST)
.detail("Validation failed. Check 'fieldErrors' for details.")
.errorCode("VALIDATION_FAILED")
.traceId(traceId)
.fieldErrors(fieldErrors)
.build();
return ResponseEntity.badRequest().body(error);
}
// Handle constraint violations (path variables, query params)
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiError> handleConstraintViolation(
ConstraintViolationException ex,
HttpServletRequest request) {
String traceId = generateTraceId();
List<FieldError> fieldErrors = ex.getConstraintViolations()
.stream()
.map(violation -> new FieldError(
violation.getPropertyPath().toString(),
violation.getInvalidValue(),
violation.getMessage()
))
.toList();
log.warn("Constraint violation [traceId={}]: {}", traceId, ex.getMessage());
ApiError error = ApiError.builder()
.status(HttpStatus.BAD_REQUEST)
.detail("Constraint validation failed")
.errorCode("CONSTRAINT_VIOLATION")
.instance(request.getRequestURI())
.traceId(traceId)
.fieldErrors(fieldErrors)
.build();
return ResponseEntity.badRequest().body(error);
}
// Handle JSON parsing errors
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiError> handleHttpMessageNotReadable(
HttpMessageNotReadableException ex,
HttpServletRequest request) {
String traceId = generateTraceId();
log.warn("Malformed JSON [traceId={}]: {}", traceId, ex.getMessage());
ApiError error = ApiError.builder()
.status(HttpStatus.BAD_REQUEST)
.detail("Malformed JSON request body")
.errorCode("MALFORMED_JSON")
.instance(request.getRequestURI())
.traceId(traceId)
.build();
return ResponseEntity.badRequest().body(error);
}
// Handle access denied (Spring Security)
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiError> handleAccessDenied(
AccessDeniedException ex,
HttpServletRequest request) {
String traceId = generateTraceId();
log.warn("Access denied [traceId={}]: {}", traceId, ex.getMessage());
ApiError error = ApiError.builder()
.status(HttpStatus.FORBIDDEN)
.detail("You don't have permission to access this resource")
.errorCode("ACCESS_DENIED")
.instance(request.getRequestURI())
.traceId(traceId)
.build();
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);
}
// Handle all unexpected exceptions
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleAllUncaughtException(
Exception ex,
HttpServletRequest request) {
String traceId = generateTraceId();
// Log full stack trace for unexpected errors
log.error("Unexpected error [traceId={}]: {}", traceId, ex.getMessage(), ex);
// Don't expose internal details to client
ApiError error = ApiError.builder()
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.detail("An unexpected error occurred. Please try again later.")
.errorCode("INTERNAL_ERROR")
.instance(request.getRequestURI())
.traceId(traceId)
.build();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
private String generateTraceId() {
return UUID.randomUUID().toString().substring(0, 8);
}
}
Step 4: Implement Service Layer
Use your custom exceptions in service methods:
@Service
@Transactional(readOnly = true)
public class ProductService {
private final ProductRepository productRepository;
private final CategoryRepository categoryRepository;
public ProductService(ProductRepository productRepository,
CategoryRepository categoryRepository) {
this.productRepository = productRepository;
this.categoryRepository = categoryRepository;
}
public Product getProduct(Long id) {
return productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product", id.toString()));
}
@Transactional
public Product createProduct(CreateProductRequest request) {
// Check for duplicates
if (productRepository.existsBySku(request.sku())) {
throw new ConflictException("Product", "SKU", request.sku());
}
// Validate category exists
Category category = categoryRepository.findById(request.categoryId())
.orElseThrow(() -> new ResourceNotFoundException("Category", request.categoryId().toString()));
// Business validation
if (request.price().compareTo(BigDecimal.ZERO) <= 0) {
throw new BusinessValidationException(
"Product price must be greater than zero",
"INVALID_PRICE",
Map.of("providedPrice", request.price())
);
}
Product product = new Product();
product.setName(request.name());
product.setSku(request.sku());
product.setPrice(request.price());
product.setCategory(category);
return productRepository.save(product);
}
@Transactional
public Product updateProduct(Long id, UpdateProductRequest request) {
Product product = getProduct(id);
// Check SKU uniqueness if changed
if (!product.getSku().equals(request.sku())
&& productRepository.existsBySku(request.sku())) {
throw new ConflictException("Product", "SKU", request.sku());
}
product.setName(request.name());
product.setSku(request.sku());
product.setPrice(request.price());
return productRepository.save(product);
}
@Transactional
public void deleteProduct(Long id) {
Product product = getProduct(id);
// Business rule: can't delete products with pending orders
if (orderRepository.existsPendingOrdersForProduct(id)) {
throw new BusinessValidationException(
"Cannot delete product with pending orders",
"PRODUCT_HAS_PENDING_ORDERS",
Map.of("productId", id)
);
}
productRepository.delete(product);
}
}
Step 5: Controller with Validation
@RestController
@RequestMapping("/api/v1/products")
@Validated
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping("/{id}")
public ResponseEntity<ProductResponse> getProduct(
@PathVariable @Min(1) Long id) {
Product product = productService.getProduct(id);
return ResponseEntity.ok(ProductResponse.from(product));
}
@PostMapping
public ResponseEntity<ProductResponse> createProduct(
@Valid @RequestBody CreateProductRequest request) {
Product product = productService.createProduct(request);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(product.getId())
.toUri();
return ResponseEntity.created(location).body(ProductResponse.from(product));
}
@PutMapping("/{id}")
public ResponseEntity<ProductResponse> updateProduct(
@PathVariable @Min(1) Long id,
@Valid @RequestBody UpdateProductRequest request) {
Product product = productService.updateProduct(id, request);
return ResponseEntity.ok(ProductResponse.from(product));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteProduct(
@PathVariable @Min(1) Long id) {
productService.deleteProduct(id);
return ResponseEntity.noContent().build();
}
}
// Request DTOs with validation
public record CreateProductRequest(
@NotBlank(message = "Name is required")
@Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
String name,
@NotBlank(message = "SKU is required")
@Pattern(regexp = "^[A-Z0-9-]+$", message = "SKU must contain only uppercase letters, numbers, and hyphens")
String sku,
@NotNull(message = "Price is required")
@DecimalMin(value = "0.01", message = "Price must be at least 0.01")
BigDecimal price,
@NotNull(message = "Category ID is required")
Long categoryId
) {}
Internationalization Support
Add i18n support for error messages:
@Component
public class MessageResolver {
private final MessageSource messageSource;
public MessageResolver(MessageSource messageSource) {
this.messageSource = messageSource;
}
public String resolve(String code, Object... args) {
return messageSource.getMessage(
code,
args,
code, // default to code if not found
LocaleContextHolder.getLocale()
);
}
}
// messages.properties
error.resource.not.found={0} with identifier ''{1}'' was not found
error.validation.failed=Validation failed. Please check your input.
error.access.denied=You don't have permission to perform this action
error.internal=An unexpected error occurred. Please try again later.
// messages_es.properties
error.resource.not.found={0} con identificador ''{1}'' no fue encontrado
error.validation.failed=Validacion fallida. Por favor revise su entrada.
error.access.denied=No tiene permiso para realizar esta accion
error.internal=Ocurrio un error inesperado. Por favor intente mas tarde.
Common Mistakes to Avoid
Exposing Stack Traces in Production
Never return full stack traces to clients. Log them server-side but return generic messages to users. Use the traceId pattern to correlate logs with user reports.
Using Generic Exceptions
Don't throw RuntimeException or Exception directly. Create specific exception classes that carry meaningful context about what went wrong.
Inconsistent Error Formats
Always return the same error structure. API consumers rely on consistent formats to handle errors programmatically.
Missing HTTP Status Codes
Use appropriate HTTP status codes: 400 for client errors, 401 for authentication, 403 for authorization, 404 for not found, 422 for business logic errors, 500 for server errors.
Not Logging Enough Context
Include traceId, request path, user ID (if authenticated), and relevant business context in your logs. This makes debugging production issues much easier.
Sample Error Responses
// 404 - Resource not found
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "Product with identifier '999' not found",
"instance": "/api/v1/products/999",
"errorCode": "RESOURCE_NOT_FOUND",
"timestamp": "2025-01-06T10:30:00Z",
"traceId": "a1b2c3d4",
"details": {
"resourceType": "Product",
"identifier": "999"
}
}
// 400 - Validation error
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Validation failed. Check 'fieldErrors' for details.",
"errorCode": "VALIDATION_FAILED",
"timestamp": "2025-01-06T10:30:00Z",
"traceId": "e5f6g7h8",
"fieldErrors": [
{
"field": "name",
"rejectedValue": "",
"message": "Name is required"
},
{
"field": "price",
"rejectedValue": -10,
"message": "Price must be at least 0.01"
}
]
}
Final Thoughts
Handling exceptions globally in Spring Boot is clean, scalable, and highly recommended. It improves your API's consistency, user experience, and overall reliability—especially in production environments.
By using @RestControllerAdvice, a custom exception hierarchy, and structured responses following RFC 7807, you can handle all edge cases in one place while keeping your controller logic focused on business functionality.
For more Spring Boot best practices, check out our guide on Getting Started with Spring Boot 3 and explore the official Spring Boot error handling documentation.