How to Map Exceptions to HTTP Status Codes in Spring Boot
Where does the exception-to-HTTP conversion happen in a Spring Boot application? This is a question I often ask myself when designing REST APIs. Should my service layer know about HTTP status codes? Should my controllers be littered with try-catch blocks? Let me show you three approaches to handle this cleanly.
The Core Principle
Before diving into the approaches, there’s one principle I always follow: the service layer should remain HTTP-agnostic. My service classes throw domain exceptions like UserNotFoundException or InsufficientBalanceException. They don’t know or care about HTTP 404 or 403. The HTTP translation happens at the controller layer, keeping my architecture clean and testable.
Approach 1: @ResponseStatus Annotation
The simplest approach is using @ResponseStatus on your custom exception classes. When such an exception is thrown and not caught, Spring automatically returns the specified HTTP status code.
import org.springframework.http.HttpStatus;import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)public class UserNotFoundException extends RuntimeException { public UserNotFoundException(String message) { super(message); }}When this exception bubbles up to the controller layer, Spring returns a 404 response. Here’s how I use it in my service:
@Servicepublic class UserService { private final UserRepository userRepository;
public User getUserById(Long id) { return userRepository.findById(id) .orElseThrow(() -> new UserNotFoundException("User not found with id: " + id)); }}No try-catch in the controller, no HTTP code awareness in the service. Clean and simple.
Pros:
- Minimal code
- Declarative and easy to understand
- Exception class itself defines the HTTP status
Cons:
- Status code is fixed at compile time
- Limited control over response body
- Not ideal for complex error responses
Approach 2: ResponseStatusException
For scenarios where the HTTP status code depends on runtime conditions, ResponseStatusException provides more flexibility. Instead of annotating an exception class, I throw this exception directly with the desired status.
import org.springframework.web.server.ResponseStatusException;import org.springframework.http.HttpStatus;
@Servicepublic class PaymentService { public void processPayment(PaymentRequest request) { if (request.getAmount().compareTo(BigDecimal.ZERO) <= 0) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Payment amount must be positive" ); }
Account account = accountRepository.findById(request.getAccountId()) .orElseThrow(() -> new ResponseStatusException( HttpStatus.NOT_FOUND, "Account not found" ));
if (account.getBalance().compareTo(request.getAmount()) < 0) { throw new ResponseStatusException( HttpStatus.PAYMENT_REQUIRED, "Insufficient balance" ); }
// Process payment... }}This approach gives me runtime flexibility. The same method can return different status codes based on different conditions.
Pros:
- Dynamic status code selection
- Can include custom reason messages
- No need for custom exception classes
Cons:
- Service layer becomes aware of HTTP concepts
- Less structured than custom exception hierarchy
- Scattered exception handling logic
Approach 3: @ExceptionHandler with @ControllerAdvice
This is my preferred approach for production applications. I keep my service layer throwing domain exceptions, and handle all HTTP translation in a centralized @ControllerAdvice class.
First, I define my domain exceptions without any HTTP annotations:
public class UserNotFoundException extends RuntimeException { private final Long userId;
public UserNotFoundException(Long userId) { super("User not found with id: " + userId); this.userId = userId; }
public Long getUserId() { return userId; }}
public class InsufficientBalanceException extends RuntimeException { private final BigDecimal required; private final BigDecimal available;
public InsufficientBalanceException(BigDecimal required, BigDecimal available) { super(String.format("Insufficient balance. Required: %s, Available: %s", required, available)); this.required = required; this.available = available; }
public BigDecimal getRequired() { return required; }
public BigDecimal getAvailable() { return available; }}Then I create a centralized exception handler:
import org.springframework.web.bind.annotation.ExceptionHandler;import org.springframework.web.bind.annotation.RestControllerAdvice;import org.springframework.http.ResponseEntity;import org.springframework.http.HttpStatus;
@RestControllerAdvicepublic class GlobalExceptionHandler {
@ExceptionHandler(UserNotFoundException.class) public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException ex) { ErrorResponse error = new ErrorResponse( "USER_NOT_FOUND", ex.getMessage(), Map.of("userId", ex.getUserId()) ); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); }
@ExceptionHandler(InsufficientBalanceException.class) public ResponseEntity<ErrorResponse> handleInsufficientBalance( InsufficientBalanceException ex) { ErrorResponse error = new ErrorResponse( "INSUFFICIENT_BALANCE", ex.getMessage(), Map.of( "required", ex.getRequired(), "available", ex.getAvailable() ) ); return ResponseEntity.status(HttpStatus.PAYMENT_REQUIRED).body(error); }
@ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) { ErrorResponse error = new ErrorResponse( "INTERNAL_ERROR", "An unexpected error occurred" ); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); }}With a consistent error response structure:
public record ErrorResponse( String code, String message, Map<String, Object> details) { public ErrorResponse(String code, String message) { this(code, message, Map.of()); }}Now my service layer stays clean:
@Servicepublic class UserService { private final UserRepository userRepository;
public User getUserById(Long id) { return userRepository.findById(id) .orElseThrow(() -> new UserNotFoundException(id)); }}The HTTP translation is completely centralized, my error responses are consistent, and I can add logging, metrics, or any other cross-cutting concerns in one place.
Pros:
- Complete separation of concerns
- Full control over response body
- Centralized error handling logic
- Easy to add logging, metrics, etc.
- Consistent error response format
Cons:
- More code to set up initially
- Requires understanding of
@ControllerAdvice
Comparison Table
| Approach | Status Control | Response Body | Service HTTP-Aware | Use Case ||------------------------|----------------|---------------|--------------------|-----------------------|| @ResponseStatus | Static | Limited | No | Simple apps || ResponseStatusException| Dynamic | Limited | Yes | Inline decisions || @ControllerAdvice | Full | Full | No | Production apps |Best Practices
When implementing exception handling in Spring Boot, I follow these guidelines:
-
Keep service layer HTTP-agnostic: Throw domain exceptions, not HTTP exceptions.
-
Use specific exception types: Instead of a generic
BusinessException, haveUserNotFoundException,DuplicateEmailException, etc. -
Consistent error response structure: All errors should follow the same JSON structure.
-
Appropriate logging: Log exceptions at the appropriate level. Don’t log 404s as errors.
-
Don’t expose internal details: Error messages should be helpful but not reveal system internals.
@RestControllerAdvicepublic class GlobalExceptionHandler { private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(UserNotFoundException.class) public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException ex) { // 404 is expected - log at DEBUG level log.debug("User not found: {}", ex.getUserId()); return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(new ErrorResponse("USER_NOT_FOUND", ex.getMessage())); }
@ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> handleUnexpectedException(Exception ex) { // Unexpected errors - log with stack trace log.error("Unexpected error occurred", ex); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred")); }}Which Approach Should You Choose?
For simple applications or prototypes, @ResponseStatus is perfectly fine. It’s quick to implement and easy to understand.
For specific scenarios where status depends on runtime conditions and you don’t mind your service layer knowing about HTTP, ResponseStatusException works well.
For production applications, I always go with @ControllerAdvice. The initial setup is worth it for the clean separation of concerns, consistent error responses, and centralized control. Your future self (and your teammates) will thank you when debugging issues or adding new error types.
The key insight is this: exception handling is a cross-cutting concern. Treat it as such. Keep your business logic focused on business rules, and let the controller advice handle the HTTP translation.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Spring Framework Documentation: Exception Handling
- 👨💻 Reddit Discussion: ControllerAdvice and RestControllerAdvice
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments