In a perfect world, your REST API always works flawlessly. But in the real world? Things break.
How your application handles those failures can define the difference between a developer’s dream and a support team’s nightmare.
In this post, you’ll learn how to implement robust, consistent, and professional-grade exception handling in Spring Boot. We’ll go beyond catching errors—we’ll turn exceptions into structured, debuggable, and meaningful responses.
You’ll walk away knowing:
- Why default exception handling isn’t enough
- How to use
@ControllerAdvice
and@ExceptionHandler
effectively - How to structure standardized error responses
- What to log—and what not to expose
- Strategies to keep your responses useful in production and in development
Why You Should Care About Exception Handling
Imagine your API throws a NullPointerException
, and the client receives a 500 error with this message:
Internal Server Error
That’s not just unhelpful—it’s dangerous. It tells the client nothing, hides the actual cause, and makes debugging a guessing game.
Now imagine instead they get:
{
"timestamp": "2025-06-16T09:30:00Z",
"status": 400,
"error": "Bad Request",
"message": "User ID must not be null",
"path": "/api/users"
}
Suddenly, your API feels like it’s built by pros.
The Default Behavior in Spring Boot
Out of the box, Spring Boot does handle exceptions—sort of. If you throw an exception like IllegalArgumentException
, you’ll get a generic response with a stack trace in the dev environment and a blank 500 error in production.
But if you want control over the response format, HTTP status codes, and the message’s clarity—you’ll need to override this.
Introducing @ControllerAdvice
and @ExceptionHandler
Spring gives you the perfect tools: @ControllerAdvice
and @ExceptionHandler
.
Let’s start with a simple global exception handler.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleIllegalArgument(IllegalArgumentException ex, HttpServletRequest request) {
return new ErrorResponse(
Instant.now(),
HttpStatus.BAD_REQUEST.value(),
"Bad Request",
ex.getMessage(),
request.getRequestURI()
);
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleGenericException(Exception ex, HttpServletRequest request) {
return new ErrorResponse(
Instant.now(),
HttpStatus.INTERNAL_SERVER_ERROR.value(),
"Internal Server Error",
"An unexpected error occurred",
request.getRequestURI()
);
}
}
And here’s the ErrorResponse
class:
public record ErrorResponse(
Instant timestamp,
int status,
String error,
String message,
String path
) {}
This structure is clean, informative, and ready to be extended.


Custom Exceptions Are Your Best Friends
Let’s say you have a use case where a user isn’t found. Throwing a RuntimeException
is vague and unprofessional. Instead:
public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(Long userId) {
super("User with ID " + userId + " not found");
}
}
And handle it specifically:
@ExceptionHandler(UserNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleUserNotFound(UserNotFoundException ex, HttpServletRequest request) {
return new ErrorResponse(
Instant.now(),
HttpStatus.NOT_FOUND.value(),
"Not Found",
ex.getMessage(),
request.getRequestURI()
);
}
Avoid Leaking Sensitive Info
A common mistake: returning exception stack traces or internal error messages to clients.
Bad:
{
"message": "org.hibernate.exception.SQLGrammarException: syntax error at or near \\"DROP\\""
}
This exposes internal tech and risks security.
Good:
{
"message": "An error occurred while processing your request. Please try again later."
}
Log the details. Don’t expose them.
Strategy for Logging vs. Client Messaging
Environment | Client Message | Log Message |
---|---|---|
Dev | Full exception message | Full stack trace |
Production | Generic user-friendly message | Detailed exception with request ID |
Tip: Add a traceId
to every error response and correlate it with logs for better debugging.
Bonus: Return Validation Errors Nicely
Spring can handle @Valid
and return a list of errors, but you can customize it:
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ValidationErrorResponse handleValidationErrors(MethodArgumentNotValidException ex) {
List<FieldError> errors = ex.getBindingResult().getFieldErrors();
List<FieldValidationError> fieldErrors = errors.stream()
.map(err -> new FieldValidationError(err.getField(), err.getDefaultMessage()))
.toList();
return new ValidationErrorResponse(
Instant.now(),
HttpStatus.BAD_REQUEST.value(),
"Validation Failed",
fieldErrors
);
}
public record FieldValidationError(String field, String message) {}
public record ValidationErrorResponse(
Instant timestamp,
int status,
String error,
List<FieldValidationError> errors
) {}
Common Spring Exceptions and Recommended HTTP Codes Cheat Sheet

Keep It Clean with Layers
- Use service-layer exceptions (
UserNotFoundException
,InsufficientFundsException
) - Catch and convert low-level exceptions into these domain-specific ones
- Let the
@ControllerAdvice
translate them to meaningful responses
Final Thoughts: Build for Devs, Protect for Users
Well-structured error handling isn’t just a technical detail. It’s a signal of quality. It improves:
- Developer productivity during integration
- Client trust when things go wrong
- Production observability with traceability and structured logs
You’re not just handling errors. You’re designing a safer, clearer, and more professional API.
✅ TL;DR Checklist
- [ ] Use
@ControllerAdvice
and@ExceptionHandler
- [ ] Create structured error response DTOs
- [ ] Avoid leaking internal info
- [ ] Add
traceId
for observability - [ ] Customize validation error outputs
- [ ] Map domain exceptions to HTTP status codes
🔗 Want More?
If you liked this post, you’ll love my eBook on Mastering Spring Boot Project Structure—with real-world organization tips for scalable, professional APIs.