Spring Boot Global Exception Handling Tutorial
Watch on YouTubeSpring Boot Global Exception Handling
In a real REST API, failures are normal: a product may not exist, a request may contain invalid data, or a database may be temporarily unavailable. Global exception handling converts these failures into clear, consistent JSON responses for the frontend or API client.
Choose the correct HTTP status
| Situation | Status | Meaning |
|---|---|---|
| Invalid JSON or validation failure | 400 Bad Request | Client must correct the request |
| Missing or invalid login token | 401 Unauthorized | Authentication is required |
| Authenticated user lacks permission | 403 Forbidden | Access is not allowed |
| Product or employee does not exist | 404 Not Found | Requested resource is missing |
| Duplicate email or business conflict | 409 Conflict | Request conflicts with current data |
| Unexpected server or database failure | 500 Internal Server Error | Server failed safely |
What is a global exception handler?
A global handler catches exceptions after they leave a controller and
converts them into one predictable response format. Controllers stay
focused on successful requests instead of repeating
try/catch
blocks.
@RestControllerAdvice
@RestControllerAdvice applies to all REST controllers in
the application. It combines controller advice with response-body
behavior, so handler methods automatically return JSON.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
public ResponseEntity<ApiError> productNotFound(
ProductNotFoundException ex,
HttpServletRequest request) {
ApiError error = new ApiError(
Instant.now(),
404,
"Product Not Found",
ex.getMessage(),
request.getRequestURI());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
}
Create one consistent error response
public record ApiError(
Instant timestamp,
int status,
String error,
String message,
String path) {
}
The client always receives the same shape, for example:
{
"timestamp": "2026-07-24T10:15:30Z",
"status": 404,
"error": "Product Not Found",
"message": "Product 42 was not found",
"path": "/api/products/42"
}
@ExceptionHandler
@ExceptionHandler maps one or more exception types to a
handler method. Handle specific, expected exceptions first, then use a
final generic handler for unexpected failures.
@ExceptionHandler({
ProductNotFoundException.class,
EmployeeNotFoundException.class
})
public ResponseEntity<ApiError> resourceNotFound(
RuntimeException ex,
HttpServletRequest request) {
return error(HttpStatus.NOT_FOUND, "Resource Not Found",
ex.getMessage(), request.getRequestURI());
}
Create business exceptions
A custom exception gives a business failure a clear name. It is better
than throwing a generic RuntimeException because the global
handler can map it to the correct status code.
public class ProductNotFoundException
extends RuntimeException {
public ProductNotFoundException(Long id) {
super("Product " + id + " was not found");
}
}
public class DuplicateEmailException
extends RuntimeException {
public DuplicateEmailException(String email) {
super("Email is already registered: " + email);
}
}
End-to-end product API example
This flow loads a product from a repository. If the product is missing,
the service throws ProductNotFoundException; the controller
does not need to catch it because the advice handles it globally.
1. Repository
public interface ProductRepository
extends JpaRepository<Product, Long> {
}
2. Service
@Service
public class ProductService {
private final ProductRepository repository;
public ProductService(ProductRepository repository) {
this.repository = repository;
}
@Transactional(readOnly = true)
public Product findById(Long id) {
return repository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
}
3. REST controller
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService service;
public ProductController(ProductService service) {
this.service = service;
}
@GetMapping("/{id}")
public Product findById(@PathVariable Long id) {
return service.findById(id);
}
}
4. Complete global handler
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
public ResponseEntity<ApiError> notFound(
ProductNotFoundException ex,
HttpServletRequest request) {
return error(HttpStatus.NOT_FOUND, "Product Not Found",
ex.getMessage(), request.getRequestURI());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationError> validation(
MethodArgumentNotValidException ex) {
Map<String, String> fields = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors().forEach(field ->
fields.put(field.getField(), field.getDefaultMessage()));
return ResponseEntity.badRequest().body(
new ValidationError(400, "Validation Failed", fields));
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ApiError> conflict(
DataIntegrityViolationException ex,
HttpServletRequest request) {
return error(HttpStatus.CONFLICT, "Data Conflict",
"The request conflicts with existing data",
request.getRequestURI());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> unexpected(
Exception ex, HttpServletRequest request) {
log.error("Unexpected error for {}", request.getRequestURI(), ex);
return error(HttpStatus.INTERNAL_SERVER_ERROR,
"Internal Server Error",
"Something went wrong. Try again later.",
request.getRequestURI());
}
private ResponseEntity<ApiError> error(
HttpStatus status, String title, String message, String path) {
return ResponseEntity.status(status).body(new ApiError(
Instant.now(), status.value(), title, message, path));
}
}
Handle validation errors
Add validation annotations to the request DTO and use
@Valid in the controller. The handler returns field-level
messages that a frontend can display beside each input.
public record CreateProductRequest(
@NotBlank(message = "Name is required") String name,
@Positive(message = "Price must be greater than zero")
BigDecimal price) {
}
@PostMapping
public Product create(
@Valid @RequestBody CreateProductRequest request) {
return service.create(request);
}
public record ValidationError(
int status,
String error,
Map<String, String> fields) {
}
Handle framework and security errors
Some errors happen before a controller is called. For example, Spring
Security uses an AuthenticationEntryPoint for 401 responses
and an AccessDeniedHandler for 403 responses. Configure
both to return the same JSON style as the rest of the API.
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint((request, response, ex) ->
response.sendError(401, "Authentication required"))
.accessDeniedHandler((request, response, ex) ->
response.sendError(403, "Access denied")));
return http.build();
}
Log errors safely
Log technical details on the server with a request or correlation ID. Return a safe message to the client. Never expose stack traces, SQL, passwords, JWTs, or internal file paths in a production response.
private static final Logger log =
LoggerFactory.getLogger(GlobalExceptionHandler.class);
log.error("Unexpected error for requestId={}", requestId, ex);
Best practices
- Use one consistent error response shape across endpoints.
- Map expected business exceptions to intentional HTTP status codes.
- Keep public messages safe and put technical details in server logs.
-
Do not catch
Exceptioneverywhere; handle failures where you can add useful context. - Do not return
200 OKfor an error. - Test successful responses and error responses separately.
- Use a correlation ID to trace one request across services.
Continue learning
