Skip to content

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.

UserNotFoundException.java
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:

UserService.java
@Service
public 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.

PaymentService.java
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
@Service
public 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:

DomainExceptions.java
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:

GlobalExceptionHandler.java
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
@RestControllerAdvice
public 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:

ErrorResponse.java
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:

UserService.java
@Service
public 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:

  1. Keep service layer HTTP-agnostic: Throw domain exceptions, not HTTP exceptions.

  2. Use specific exception types: Instead of a generic BusinessException, have UserNotFoundException, DuplicateEmailException, etc.

  3. Consistent error response structure: All errors should follow the same JSON structure.

  4. Appropriate logging: Log exceptions at the appropriate level. Don’t log 404s as errors.

  5. Don’t expose internal details: Error messages should be helpful but not reveal system internals.

LoggingBestPractice.java
@RestControllerAdvice
public 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments