Spring boot exception handling is what turns a functional API into a dependable one. A client may request a user that does not exist, submit an invalid email address, try to create a duplicate account, or trigger an unexpected database failure. Your API still needs to return a clear, predictable response. Without a deliberate error strategy, callers receive inconsistent payloads, misleading status codes, or internal details that should never leave the server.
This guide builds a production-minded global exception handler using Java 21, Spring Boot 3.x, and Gradle. You will create an error DTO, custom exceptions, a @ControllerAdvice class, validation handlers, controller and service examples, JSON responses, Postman checks, cURL commands, and practical guidance for logs, monitoring, and security.
Start with Spring Boot Getting Started if controllers and request mappings are new to you. For a working API foundation, see How to Build a REST API with Spring Boot in 30 Minutes.
Why Spring Boot Exception Handling Matters
HTTP status codes are part of your API contract. A frontend, mobile application, integration partner, and observability system all use them to decide what happened and what to do next. A 400 Bad Request tells a user to correct input. A 404 Not Found tells a client that a resource is absent. A 409 Conflict explains that the request is valid but violates current state. A 500 Internal Server Error tells callers that retrying later may be reasonable.
Default error responses are useful during early development, but they do not define a stable public contract. They can vary by Spring Boot configuration, omit field-level validation detail, and encourage controllers full of repetitive try/catch blocks. In a real API, errors should have one predictable shape, useful messages for expected failures, and safe messages for unexpected failures.
Think about an account registration endpoint. If an email is malformed, the client should receive which field is wrong. If the email already exists, it should receive a conflict response. If PostgreSQL is unavailable, it should receive a generic server error while your logs preserve the technical cause. Each outcome needs a different response, but none should require the controller to know logging and serialization details.
What Is Exception Handling?
An exception interrupts normal program flow when something goes wrong or when code deliberately signals an invalid state. Java's exception hierarchy lets us distinguish failures that callers are expected to recover from and programming or infrastructure failures that need application-level handling.
Checked Exceptions
Checked exceptions extend Exception but not RuntimeException. Java requires the method to catch or declare them. File and network APIs may use checked exceptions. Spring REST applications rarely use checked exceptions for ordinary business rules because they make service signatures noisy without improving HTTP error handling.
Unchecked and Runtime Exceptions
Unchecked exceptions extend RuntimeException. You do not need to declare them in a method signature. Custom API exceptions such as ResourceNotFoundException and BusinessException commonly extend RuntimeException, then a global handler maps them to HTTP responses. This keeps business methods readable while making failure behavior explicit.
Common API Errors
- Resource not found: A requested user, order, or product ID does not exist.
- Validation failure: A required field is blank, an email is invalid, or a number is outside its allowed range.
- Database error: A unique constraint is violated, a connection fails, or a query cannot complete.
- Unauthorized request: A caller has not authenticated or does not have permission for the action.
Do not treat every failure as a 500. Expected client mistakes deserve a 4xx response; unexpected server-side conditions deserve a 5xx response. This distinction improves user experience, retries, alerting, and client-side logic.
Default Spring Boot Error Response
When an exception escapes a controller without your own handler, Spring Boot's default error infrastructure often returns fields such as timestamp, status, error, path, and sometimes a message. That is helpful for exploration, but it is not enough for a durable API. You may want stable error codes, field validation errors, a correlation ID, and a message that does not disclose internal state.
Project Setup
Create a Gradle project with Java 21 and Spring Boot 3.x. Add Spring Web and Validation. The sample below stores users in memory to keep attention on error handling; the same pattern works with Spring Data JPA and PostgreSQL.
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.5'
id 'io.spring.dependency-management' version '1.1.6'
}
group = 'com.digitaldrift'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
Folder Structure
src/main/java/com/digitaldrift/exceptiondemo/
โโโ ExceptionDemoApplication.java
โโโ controller/
โ โโโ UserController.java
โโโ dto/
โ โโโ CreateUserRequest.java
โ โโโ ErrorResponse.java
โโโ exception/
โ โโโ BusinessException.java
โ โโโ GlobalExceptionHandler.java
โ โโโ ResourceNotFoundException.java
โโโ service/
โโโ UserService.java
Complete Source Code
Application Class
package com.digitaldrift.exceptiondemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ExceptionDemoApplication {
public static void main(String[] args) {
SpringApplication.run(ExceptionDemoApplication.class, args);
}
}
Create ErrorResponse DTO
A response DTO is the center of a consistent rest api error handling spring boot design. A Java record is concise and immutable. It includes an application-specific error code and an optional map for validation fields.
package com.digitaldrift.exceptiondemo.dto;
import java.time.Instant;
import java.util.Map;
public record ErrorResponse(
Instant timestamp,
int status,
String error,
String code,
String message,
String path,
Map<String, String> validationErrors
) {
}
Validation Request DTO
Bean Validation keeps malformed input out of the service layer. Add @Valid to the controller request parameter to activate these annotations.
package com.digitaldrift.exceptiondemo.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreateUserRequest(
@NotBlank(message = "Name is required")
@Size(min = 2, max = 80, message = "Name must be between 2 and 80 characters")
String name,
@NotBlank(message = "Email is required")
@Email(message = "Email must be valid")
String email,
@Min(value = 18, message = "Age must be at least 18")
@Max(value = 120, message = "Age must not exceed 120")
int age
) {
}
Create Custom Exceptions
Custom exceptions tell the global handler what kind of failure occurred. They should represent application meaning, not replace every Java exception in the system.
package com.digitaldrift.exceptiondemo.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
package com.digitaldrift.exceptiondemo.exception;
public class BusinessException extends RuntimeException {
public BusinessException(String message) {
super(message);
}
}
Global Exception Handler: @ControllerAdvice and @ExceptionHandler
@ControllerAdvice applies controller-related behavior across the application. Each @ExceptionHandler method catches a type, creates the standard response, and chooses an HTTP status. This is the core of a controlleradvice spring boot implementation.
package com.digitaldrift.exceptiondemo.exception;
import com.digitaldrift.exceptiondemo.dto.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
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.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(
ResourceNotFoundException exception,
HttpServletRequest request
) {
return buildResponse(HttpStatus.NOT_FOUND, "RESOURCE_NOT_FOUND",
exception.getMessage(), request, Map.of());
}
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(
BusinessException exception,
HttpServletRequest request
) {
return buildResponse(HttpStatus.CONFLICT, "BUSINESS_RULE_VIOLATION",
exception.getMessage(), request, Map.of());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
MethodArgumentNotValidException exception,
HttpServletRequest request
) {
Map<String, String> errors = new LinkedHashMap<>();
for (FieldError error : exception.getBindingResult().getFieldErrors()) {
errors.put(error.getField(), error.getDefaultMessage());
}
return buildResponse(HttpStatus.BAD_REQUEST, "VALIDATION_FAILED",
"One or more fields are invalid", request, errors);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleIllegalArgument(
IllegalArgumentException exception,
HttpServletRequest request
) {
return buildResponse(HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT",
exception.getMessage(), request, Map.of());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(
Exception exception,
HttpServletRequest request
) {
// Log exception details here with your application's logger.
return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_ERROR",
"An unexpected error occurred", request, Map.of());
}
private ResponseEntity<ErrorResponse> buildResponse(
HttpStatus status, String code, String message,
HttpServletRequest request, Map<String, String> validationErrors
) {
ErrorResponse response = new ErrorResponse(
Instant.now(), status.value(), status.getReasonPhrase(), code,
message, request.getRequestURI(), validationErrors
);
return ResponseEntity.status(status).body(response);
}
}
The generic handler must come last conceptually: Spring selects the most specific matching exception handler. It gives unknown failures a safe response. Log the original exception on the server, but do not send its stack trace, database driver message, or configuration details to clients.
Service Example
The service throws domain-relevant exceptions. In a database-backed application, the lookup would use a repository; the error-handling shape remains the same.
package com.digitaldrift.exceptiondemo.service;
import com.digitaldrift.exceptiondemo.dto.CreateUserRequest;
import com.digitaldrift.exceptiondemo.exception.BusinessException;
import com.digitaldrift.exceptiondemo.exception.ResourceNotFoundException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Service;
@Service
public class UserService {
private final Map<Long, CreateUserRequest> users = new ConcurrentHashMap<>();
private final AtomicLong nextId = new AtomicLong(1);
public CreateUserRequest create(CreateUserRequest request) {
boolean emailExists = users.values().stream()
.anyMatch(user -> user.email().equalsIgnoreCase(request.email()));
if (emailExists) {
throw new BusinessException("A user already exists with this email");
}
users.put(nextId.getAndIncrement(), request);
return request;
}
public CreateUserRequest getById(Long id) {
if (id <= 0) {
throw new IllegalArgumentException("User id must be greater than zero");
}
CreateUserRequest user = users.get(id);
if (user == null) {
throw new ResourceNotFoundException("User not found with id: " + id);
}
return user;
}
}
The short in-memory store only makes the exception paths easy to follow. In a real application, a database-generated ID and repository would replace it.
Controller Example
package com.digitaldrift.exceptiondemo.controller;
import com.digitaldrift.exceptiondemo.dto.CreateUserRequest;
import com.digitaldrift.exceptiondemo.service.UserService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@PostMapping
public ResponseEntity<CreateUserRequest> create(@Valid @RequestBody CreateUserRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(userService.create(request));
}
@GetMapping("/{id}")
public ResponseEntity<CreateUserRequest> getById(@PathVariable Long id) {
return ResponseEntity.ok(userService.getById(id));
}
}
Notice what is missing: no controller-level try/catch. Controllers stay focused on HTTP input and output, services own business behavior, and the spring boot global exception handler owns the error contract.
Standard API Error Format and Example Responses
Clients should be able to parse every error the same way. The values may change, but the keys should remain consistent. A useful minimum is timestamp, numeric status, reason phrase, machine-readable code, safe message, path, and validation errors.
400 Bad Request: Validation Failure
{
"timestamp": "2026-06-21T10:15:30Z",
"status": 400,
"error": "Bad Request",
"code": "VALIDATION_FAILED",
"message": "One or more fields are invalid",
"path": "/api/users",
"validationErrors": {
"name": "Name is required",
"email": "Email must be valid",
"age": "Age must be at least 18"
}
}
404 Not Found
{
"timestamp": "2026-06-21T10:16:00Z",
"status": 404,
"error": "Not Found",
"code": "RESOURCE_NOT_FOUND",
"message": "User not found with id: 99",
"path": "/api/users/99",
"validationErrors": {}
}
409 Conflict: Business Rule
{
"timestamp": "2026-06-21T10:17:00Z",
"status": 409,
"error": "Conflict",
"code": "BUSINESS_RULE_VIOLATION",
"message": "A user already exists with this email",
"path": "/api/users",
"validationErrors": {}
}
500 Internal Server Error
{
"timestamp": "2026-06-21T10:18:00Z",
"status": 500,
"error": "Internal Server Error",
"code": "INTERNAL_ERROR",
"message": "An unexpected error occurred",
"path": "/api/users/1",
"validationErrors": {}
}
Validation Examples
Use validation annotations close to the request contract. @NotBlank rejects empty text, @Email checks email syntax, @Size bounds text length, and @Min/@Max define numeric rules. These are client-facing rules; database constraints are still important as a final integrity boundary.
For nested request objects, place @Valid on the nested field too. For cross-field rules, such as a start date before an end date, create a class-level custom constraint rather than placing fragile business logic in a controller.
Best Practices
- Write meaningful messages: State what failed and how a client can correct it. Avoid vague text such as "Bad input".
- Use stable error codes: Clients can branch on
VALIDATION_FAILEDeven if the human message changes. - Keep one response structure: Do not make validation errors look unrelated to not-found errors.
- Log at the right level: Expected 4xx errors may be info or warn; unexpected 5xx failures need error logs with the exception.
- Map exceptions intentionally: An exception type should have one clear HTTP meaning.
- Do not leak internals: Never return stack traces, SQL, filesystem paths, access tokens, or secret values.
Production Considerations
Correlation IDs and Monitoring
Give each request a correlation ID, return it in a response header, and include it in logs. When a customer reports an error, the ID connects the client response to the exact server-side event. Send error metrics to monitoring tools and alert on rates, not individual expected validation failures.
Security
Authentication failures should return appropriate 401 or 403 responses without confirming sensitive account details. Spring Security can handle those before a controller is reached, so configure its authentication entry point and access-denied handler to use the same general error shape. For a complete authentication flow, see Spring Boot JWT Authentication Guide.
Persistence Errors
Do not expose raw database exceptions. Translate a known duplicate-key condition into a conflict response where appropriate and let unexpected persistence failures become safe 500 errors. The persistence context and transaction boundaries are covered in Spring Boot PostgreSQL CRUD with JPA and Hibernate.
Common Mistakes
Catching Exception Everywhere
Sprinkling broad catch (Exception) blocks through controllers duplicates logic and frequently hides the original cause. Catch an exception locally only when that layer can recover, translate, or add meaningful context. Otherwise, let the global handler do its job.
Exposing Stack Traces
Stack traces help developers but can reveal framework versions, class names, file paths, query details, and system behavior. Keep them in logs, not JSON responses.
Ignoring Validation
Skipping validation pushes bad data deeper into the system, where it creates obscure database errors or broken business logic. Validate at the API boundary, then retain database constraints as defense in depth.
Testing Error Responses
Test error handling like any other API contract. A focused MVC test verifies both status and JSON keys.
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void returnsNotFoundError() throws Exception {
given(userService.getById(99L))
.willThrow(new ResourceNotFoundException("User not found with id: 99"));
mockMvc.perform(get("/api/users/99"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND"))
.andExpect(jsonPath("$.message").value("User not found with id: 99"));
}
}
Add tests for invalid payloads, duplicate email conflicts, malformed IDs when relevant, and the generic 500 path. Treat response keys and error codes as a compatibility contract for frontend and integration teams.
Postman Examples
Start the application with ./gradlew bootRun. In Postman, send a valid POST http://localhost:8080/api/users request with Content-Type: application/json:
{
"name": "Ava Patel",
"email": "ava@example.com",
"age": 28
}
Then try a blank name, invalid email, and age below 18. Confirm that each failure returns 400, error code VALIDATION_FAILED, and a populated validationErrors object. Request GET /api/users/99 for a 404 response, then post the same email again to see a 409 conflict.
cURL Examples
curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{"name":"Ava Patel","email":"ava@example.com","age":28}'
curl http://localhost:8080/api/users/99
curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{"name":"","email":"not-an-email","age":16}'
Interview Questions and Answers
- What does @ControllerAdvice do? It centralizes cross-cutting controller behavior, including exception-to-response mapping.
- What does @ExceptionHandler do? It marks a method that handles one or more exception types.
- Why use custom exceptions? They express domain meaning and allow clear HTTP mappings without controller duplication.
- What is MethodArgumentNotValidException? Spring throws it when a
@Validrequest object fails Bean Validation. - When should you return 409 Conflict? When a valid request conflicts with current resource state, such as a duplicate unique email.
- Why include an error code? It gives API clients a stable machine-readable value independent of wording.
Frequently Asked Questions
Should every exception have its own handler?
No. Create handlers for meaningful categories that need different status codes or response behavior. Let truly unexpected exceptions fall through to one safe generic handler.
Can @RestControllerAdvice be used instead?
Yes. @RestControllerAdvice combines @ControllerAdvice with @ResponseBody, which is convenient for JSON-only REST APIs.
Should validation errors be 400 or 422?
Both are used in practice. Spring Boot applications commonly use 400 for malformed or invalid request data. Pick one policy, document it, and stay consistent.
Conclusion
Good Spring Boot exception handling gives every failed request an intentional outcome. You created a standard error DTO, custom not-found and business exceptions, a global @ControllerAdvice, validation handling for MethodArgumentNotValidException, safe generic handling, and testable client responses.
Keep the central idea simple: expected errors should help clients recover, unexpected errors should help operators investigate, and neither should expose internal implementation details. That small discipline pays off quickly as your API grows.
