Exception handling is a critical aspect of robust application development in Java and Spring Boot. This comprehensive guide covers everything you need to know about exceptions, from basic concepts to advanced microservice communication patterns.
Table of Contents
- Exception Hierarchy in Java
- Checked vs Unchecked Exceptions
- Try-Catch Blocks
- Multiple Catch Blocks
- Creating Custom Exceptions
- Partially vs Fully Checked Exceptions
- Global Exception Handler
- Handling Exceptions in Microservices with Feign Client
Exception Hierarchy in Java {#exception-hierarchy}
Java's exception hierarchy is built around the Throwable class, which serves as the root of all exception and error classes.
java.lang.Object
└── java.lang.Throwable
├── java.lang.Error
│ ├── OutOfMemoryError
│ ├── StackOverflowError
│ └── VirtualMachineError
└── java.lang.Exception
├── IOException (Checked)
├── SQLException (Checked)
├── ClassNotFoundException (Checked)
└── RuntimeException (Unchecked)
├── NullPointerException
├── IllegalArgumentException
├── IndexOutOfBoundsException
└── NumberFormatExceptionKey Components:
- Throwable: The superclass of all errors and exceptions
- Error: Serious problems that applications should not try to catch
- Exception: Conditions that applications might want to catch
- RuntimeException: Exceptions that can be thrown during normal operation
Checked vs Unchecked Exceptions
Checked Exceptions
Checked exceptions are compile-time exceptions that must be handled or declared in the method signature.
Characteristics:
- Must be caught or declared with
throws - Checked at compile time
- Extend
Exceptionbut notRuntimeException
Examples:
// IOException example
public void readFile(String filename) throws IOException {
BufferedReader reader = new BufferedReader(new FileReader(filename));
String line = reader.readLine();
reader.close();
}
// SQLException example
public User getUserById(int id) throws SQLException {
Connection conn = DriverManager.getConnection("jdbc:mysql://localhost/db");
PreparedStatement stmt = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
stmt.setInt(1, id);
ResultSet rs = stmt.executeQuery();
// Process result...
return user;
}Unchecked Exceptions
Unchecked exceptions are runtime exceptions that don't need to be explicitly handled.
Characteristics:
- Not checked at compile time
- Extend
RuntimeException - Usually indicate programming errors
Examples:
// NullPointerException
public void processUser(User user) {
String name = user.getName(); // Can throw NPE if user is null
}
// IllegalArgumentException
public void setAge(int age) {
if (age < 0) {
throw new IllegalArgumentException("Age cannot be negative");
}
}
// ArrayIndexOutOfBoundsException
public void accessArray() {
int[] numbers = {1, 2, 3};
int value = numbers[5]; // Throws exception
}Try-Catch Blocks {#try-catch-blocks}
The try-catch block is the fundamental mechanism for handling exceptions in Java.
Basic Try-Catch
public void basicTryCatch() {
try {
int result = 10 / 0; // This will throw ArithmeticException
System.out.println("Result: " + result);
} catch (ArithmeticException e) {
System.err.println("Cannot divide by zero: " + e.getMessage());
// Log the exception
logger.error("Division by zero error", e);
}
}Try-Catch-Finally
public void tryCatchFinally() {
FileInputStream file = null;
try {
file = new FileInputStream("data.txt");
// Process file
int data = file.read();
} catch (FileNotFoundException e) {
System.err.println("File not found: " + e.getMessage());
} catch (IOException e) {
System.err.println("IO Error: " + e.getMessage());
} finally {
// This block always executes
if (file != null) {
try {
file.close();
} catch (IOException e) {
System.err.println("Error closing file: " + e.getMessage());
}
}
}
}Try-With-Resources
public void tryWithResources() {
try (FileInputStream file = new FileInputStream("data.txt");
BufferedReader reader = new BufferedReader(new InputStreamReader(file))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
} catch (IOException e) {
System.err.println("Error reading file: " + e.getMessage());
}
// Resources are automatically closed
}Multiple Catch Blocks {#multiple-catch-blocks}
You can handle different types of exceptions with multiple catch blocks.
Separate Catch Blocks
public void multipleCatchBlocks() {
try {
String numberStr = "abc";
int number = Integer.parseInt(numberStr);
int result = 100 / number;
int[] array = new int[5];
array[10] = result;
} catch (NumberFormatException e) {
System.err.println("Invalid number format: " + e.getMessage());
// Handle number format exception
} catch (ArithmeticException e) {
System.err.println("Arithmetic error: " + e.getMessage());
// Handle arithmetic exception
} catch (ArrayIndexOutOfBoundsException e) {
System.err.println("Array index error: " + e.getMessage());
// Handle array index exception
} catch (Exception e) {
System.err.println("Unexpected error: " + e.getMessage());
// Handle any other exception
}
}Multi-Catch Block (Java 7+)
public void multiCatchBlock() {
try {
// Some operations that might throw exceptions
performRiskyOperation();
} catch (IOException | SQLException | NumberFormatException e) {
// Handle multiple exception types with the same logic
System.err.println("Expected error occurred: " + e.getMessage());
logError(e);
} catch (Exception e) {
System.err.println("Unexpected error: " + e.getMessage());
// Handle unexpected exceptions
}
}Creating Custom Exceptions {#custom-exceptions}
Custom exceptions help create more meaningful error handling in your applications.
Custom Checked Exception
// Custom checked exception
public class UserNotFoundException extends Exception {
private final String userId;
public UserNotFoundException(String userId) {
super("User not found with ID: " + userId);
this.userId = userId;
}
public UserNotFoundException(String userId, String message) {
super(message);
this.userId = userId;
}
public UserNotFoundException(String userId, String message, Throwable cause) {
super(message, cause);
this.userId = userId;
}
public String getUserId() {
return userId;
}
}Custom Unchecked Exception
// Custom unchecked exception
public class InvalidUserDataException extends RuntimeException {
private final String field;
private final Object value;
public InvalidUserDataException(String field, Object value) {
super(String.format("Invalid value '%s' for field '%s'", value, field));
this.field = field;
this.value = value;
}
public InvalidUserDataException(String field, Object value, String message) {
super(message);
this.field = field;
this.value = value;
}
public InvalidUserDataException(String field, Object value, Throwable cause) {
super(String.format("Invalid value '%s' for field '%s'", value, field), cause);
this.field = field;
this.value = value;
}
public String getField() {
return field;
}
public Object getValue() {
return value;
}
}Using Custom Exceptions
@Service
public class UserService {
public User findUserById(String userId) throws UserNotFoundException {
if (userId == null || userId.trim().isEmpty()) {
throw new InvalidUserDataException("userId", userId, "User ID cannot be null or empty");
}
User user = userRepository.findById(userId);
if (user == null) {
throw new UserNotFoundException(userId);
}
return user;
}
public User createUser(UserRequest request) {
if (request.getEmail() == null || !request.getEmail().contains("@")) {
throw new InvalidUserDataException("email", request.getEmail(), "Invalid email format");
}
if (request.getAge() < 0 || request.getAge() > 150) {
throw new InvalidUserDataException("age", request.getAge(), "Age must be between 0 and 150");
}
// Create user logic...
return user;
}
}Partially vs Fully Checked Exceptions {#partially-vs-fully-checked}
Fully Checked Exception
A fully checked exception is a traditional checked exception where every method in the call chain must either handle the exception or declare it.
// Fully checked exception - must be handled or declared at every level
public class DataAccessException extends Exception {
public DataAccessException(String message) {
super(message);
}
public DataAccessException(String message, Throwable cause) {
super(message, cause);
}
}
// Repository layer - declares the exception
@Repository
public class UserRepository {
public User findById(String id) throws DataAccessException {
try {
// Database operation
return database.findUser(id);
} catch (SQLException e) {
throw new DataAccessException("Failed to find user", e);
}
}
}
// Service layer - must handle or declare
@Service
public class UserService {
public User getUser(String id) throws DataAccessException {
return userRepository.findById(id); // Must declare or handle
}
}
// Controller layer - must handle or declare
@RestController
public class UserController {
public ResponseEntity<User> getUser(@PathVariable String id) {
try {
User user = userService.getUser(id);
return ResponseEntity.ok(user);
} catch (DataAccessException e) {
return ResponseEntity.status(500).build();
}
}
}Partially Checked Exception (Spring's Approach)
Spring Framework uses partially checked exceptions where the framework provides unchecked alternatives to checked exceptions.
// Spring's DataAccessException is unchecked (RuntimeException)
@Repository
public class SpringUserRepository {
@Autowired
private JdbcTemplate jdbcTemplate;
public User findById(String id) {
try {
return jdbcTemplate.queryForObject(
"SELECT * FROM users WHERE id = ?",
new Object[]{id},
new UserRowMapper()
);
} catch (EmptyResultDataAccessException e) {
// Spring converts SQLException to unchecked DataAccessException
return null;
}
// No need to declare SQLException - Spring handles it
}
}
// Service layer - clean without exception declarations
@Service
public class SpringUserService {
public User getUser(String id) {
User user = userRepository.findById(id);
if (user == null) {
throw new UserNotFoundException(id); // Custom unchecked exception
}
return user;
}
}Benefits of Each Approach
Fully Checked Exceptions:
- Force explicit error handling
- Clear contract about what can go wrong
- Compile-time safety
Partially Checked Exceptions (Spring's approach):
- Cleaner code without forced exception handling
- Flexibility to handle exceptions where appropriate
- Reduced boilerplate code
Global Exception Handler {#global-exception-handler}
Spring Boot provides @ControllerAdvice to handle exceptions globally across your application.
Creating a Global Exception Handler
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// Handle custom checked exceptions
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
log.error("User not found: {}", e.getUserId(), e);
ErrorResponse error = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.NOT_FOUND.value())
.error("User Not Found")
.message(e.getMessage())
.path(getCurrentPath())
.build();
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
// Handle custom unchecked exceptions
@ExceptionHandler(InvalidUserDataException.class)
public ResponseEntity<ErrorResponse> handleInvalidUserData(InvalidUserDataException e) {
log.error("Invalid user data - Field: {}, Value: {}", e.getField(), e.getValue(), e);
ErrorResponse error = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.BAD_REQUEST.value())
.error("Invalid Data")
.message(e.getMessage())
.path(getCurrentPath())
.details(Map.of("field", e.getField(), "value", e.getValue()))
.build();
return ResponseEntity.badRequest().body(error);
}
// Handle validation errors
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationErrors(MethodArgumentNotValidException e) {
log.error("Validation error", e);
Map<String, String> validationErrors = new HashMap<>();
e.getBindingResult().getFieldErrors().forEach(error ->
validationErrors.put(error.getField(), error.getDefaultMessage())
);
ErrorResponse error = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.BAD_REQUEST.value())
.error("Validation Failed")
.message("Invalid input data")
.path(getCurrentPath())
.details(validationErrors)
.build();
return ResponseEntity.badRequest().body(error);
}
// Handle database-related exceptions
@ExceptionHandler(DataAccessException.class)
public ResponseEntity<ErrorResponse> handleDataAccessException(DataAccessException e) {
log.error("Database error", e);
ErrorResponse error = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
.error("Database Error")
.message("An error occurred while accessing the database")
.path(getCurrentPath())
.build();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
// Handle HTTP method not allowed
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ErrorResponse> handleMethodNotAllowed(HttpRequestMethodNotSupportedException e) {
log.error("Method not allowed", e);
ErrorResponse error = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.METHOD_NOT_ALLOWED.value())
.error("Method Not Allowed")
.message(e.getMessage())
.path(getCurrentPath())
.build();
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(error);
}
// Handle generic exceptions
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(Exception e) {
log.error("Unexpected error", e);
ErrorResponse error = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
.error("Internal Server Error")
.message("An unexpected error occurred")
.path(getCurrentPath())
.build();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
private String getCurrentPath() {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes instanceof ServletRequestAttributes) {
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
return request.getRequestURI();
}
return "unknown";
}
}Error Response DTO
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ErrorResponse {
private LocalDateTime timestamp;
private int status;
private String error;
private String message;
private String path;
private Map<String, Object> details;
}Handling Exceptions in Microservices with Feign Client {#microservice-exception-handling}
When working with microservices, handling exceptions across service boundaries requires special attention, especially when using Feign clients.
Setting up Feign Client with Exception Handling
1. Create Custom Exceptions for Service Communication
// Exception for service communication errors
public class ServiceCommunicationException extends RuntimeException {
private final String serviceName;
private final int statusCode;
private final String responseBody;
public ServiceCommunicationException(String serviceName, int statusCode, String message) {
super(message);
this.serviceName = serviceName;
this.statusCode = statusCode;
this.responseBody = null;
}
public ServiceCommunicationException(String serviceName, int statusCode, String message, String responseBody) {
super(message);
this.serviceName = serviceName;
this.statusCode = statusCode;
this.responseBody = responseBody;
}
// Getters...
}
// Exception for remote service errors
public class RemoteServiceException extends RuntimeException {
private final String serviceName;
private final String operation;
private final ErrorResponse remoteError;
public RemoteServiceException(String serviceName, String operation, ErrorResponse remoteError) {
super(String.format("Remote service %s failed during %s: %s", serviceName, operation, remoteError.getMessage()));
this.serviceName = serviceName;
this.operation = operation;
this.remoteError = remoteError;
}
// Getters...
}2. Create Feign Error Decoder
@Component
public class CustomFeignErrorDecoder implements ErrorDecoder {
private final ObjectMapper objectMapper;
public CustomFeignErrorDecoder(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public Exception decode(String methodKey, Response response) {
String serviceName = extractServiceName(methodKey);
try {
String responseBody = IOUtils.toString(response.body().asInputStream(), StandardCharsets.UTF_8);
switch (response.status()) {
case 400:
return handleBadRequest(serviceName, responseBody);
case 404:
return handleNotFound(serviceName, responseBody);
case 500:
return handleInternalServerError(serviceName, responseBody);
default:
return handleGenericError(serviceName, response.status(), responseBody);
}
} catch (IOException e) {
return new ServiceCommunicationException(serviceName, response.status(),
"Failed to decode error response", null);
}
}
private Exception handleBadRequest(String serviceName, String responseBody) {
try {
ErrorResponse errorResponse = objectMapper.readValue(responseBody, ErrorResponse.class);
return new RemoteServiceException(serviceName, "validation", errorResponse);
} catch (Exception e) {
return new ServiceCommunicationException(serviceName, 400, "Bad Request", responseBody);
}
}
private Exception handleNotFound(String serviceName, String responseBody) {
try {
ErrorResponse errorResponse = objectMapper.readValue(responseBody, ErrorResponse.class);
return new RemoteServiceException(serviceName, "lookup", errorResponse);
} catch (Exception e) {
return new ServiceCommunicationException(serviceName, 404, "Resource Not Found", responseBody);
}
}
private Exception handleInternalServerError(String serviceName, String responseBody) {
try {
ErrorResponse errorResponse = objectMapper.readValue(responseBody, ErrorResponse.class);
return new RemoteServiceException(serviceName, "processing", errorResponse);
} catch (Exception e) {
return new ServiceCommunicationException(serviceName, 500, "Internal Server Error", responseBody);
}
}
private Exception handleGenericError(String serviceName, int status, String responseBody) {
return new ServiceCommunicationException(serviceName, status,
"Service communication failed", responseBody);
}
private String extractServiceName(String methodKey) {
// Extract service name from methodKey (e.g., "UserService#getUser(String)")
if (methodKey.contains("#")) {
return methodKey.substring(0, methodKey.indexOf("#"));
}
return "Unknown";
}
}3. Configure Feign Client
@FeignClient(name = "user-service", url = "${user-service.url}")
public interface UserServiceClient {
@GetMapping("/api/users/{id}")
UserDto getUser(@PathVariable("id") String id);
@PostMapping("/api/users")
UserDto createUser(@RequestBody CreateUserRequest request);
@PutMapping("/api/users/{id}")
UserDto updateUser(@PathVariable("id") String id, @RequestBody UpdateUserRequest request);
@DeleteMapping("/api/users/{id}")
void deleteUser(@PathVariable("id") String id);
}4. Configuration Class
@Configuration
@EnableFeignClients
public class FeignConfig {
@Bean
public ErrorDecoder errorDecoder(ObjectMapper objectMapper) {
return new CustomFeignErrorDecoder(objectMapper);
}
@Bean
public Retryer retryer() {
return new Retryer.Default(1000, 2000, 3);
}
@Bean
public RequestInterceptor requestInterceptor() {
return requestTemplate -> {
// Add correlation ID for tracing
String correlationId = UUID.randomUUID().toString();
requestTemplate.header("X-Correlation-ID", correlationId);
// Add authentication if needed
String token = getAuthToken();
if (token != null) {
requestTemplate.header("Authorization", "Bearer " + token);
}
};
}
private String getAuthToken() {
// Get token from security context or other source
return null;
}
}5. Service Layer with Exception Handling
@Service
@Slf4j
public class OrderService {
private final UserServiceClient userServiceClient;
private final CircuitBreaker circuitBreaker;
public OrderService(UserServiceClient userServiceClient, CircuitBreakerFactory circuitBreakerFactory) {
this.userServiceClient = userServiceClient;
this.circuitBreaker = circuitBreakerFactory.create("user-service");
}
public Order createOrder(CreateOrderRequest request) {
try {
// Validate user exists using Feign client with circuit breaker
UserDto user = circuitBreaker.executeSupplier(() -> {
try {
return userServiceClient.getUser(request.getUserId());
} catch (RemoteServiceException e) {
log.error("Remote service error when getting user {}: {}",
request.getUserId(), e.getMessage());
if (e.getRemoteError() != null && e.getRemoteError().getStatus() == 404) {
throw new UserNotFoundException(request.getUserId());
}
throw new ServiceUnavailableException("User service is currently unavailable");
} catch (ServiceCommunicationException e) {
log.error("Service communication error: {}", e.getMessage());
throw new ServiceUnavailableException("Unable to communicate with user service");
}
});
// Create order with validated user
Order order = Order.builder()
.userId(user.getId())
.userEmail(user.getEmail())
.items(request.getItems())
.totalAmount(calculateTotal(request.getItems()))
.status(OrderStatus.PENDING)
.createdAt(LocalDateTime.now())
.build();
return orderRepository.save(order);
} catch (Exception e) {
log.error("Failed to create order for user {}", request.getUserId(), e);
throw e;
}
}
public Order updateOrderWithUserInfo(String orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
try {
// Use circuit breaker with fallback
UserDto user = circuitBreaker.executeSupplier(() ->
userServiceClient.getUser(order.getUserId())
);
// Update order with latest user information
order.setUserEmail(user.getEmail());
order.setUserName(user.getName());
return orderRepository.save(order);
} catch (Exception e) {
log.warn("Failed to update user info for order {}, using cached data", orderId, e);
// Return order with existing data - graceful degradation
return order;
}
}
private BigDecimal calculateTotal(List<OrderItem> items) {
return items.stream()
.map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}6. Enhanced Global Exception Handler for Microservices
@ControllerAdvice
@Slf4j
public class MicroserviceExceptionHandler extends GlobalExceptionHandler {
// Handle service communication exceptions
@ExceptionHandler(ServiceCommunicationException.class)
public ResponseEntity<ErrorResponse> handleServiceCommunication(ServiceCommunicationException e) {
log.error("Service communication failed - Service: {}, Status: {}",
e.getServiceName(), e.getStatusCode(), e);
ErrorResponse error = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.SERVICE_UNAVAILABLE.value())
.error("Service Communication Error")
.message("Unable to communicate with " + e.getServiceName())
.path(getCurrentPath())
.details(Map.of(
"serviceName", e.getServiceName(),
"statusCode", e.getStatusCode()
))
.build();
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(error);
}
// Handle remote service exceptions
@ExceptionHandler(RemoteServiceException.class)
public ResponseEntity<ErrorResponse> handleRemoteService(RemoteServiceException e) {
log.error("Remote service error - Service: {}, Operation: {}",
e.getServiceName(), e.getOperation(), e);
// Map remote error to appropriate HTTP status
HttpStatus status = mapRemoteErrorStatus(e.getRemoteError());
ErrorResponse error = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(status.value())
.error("Remote Service Error")
.message(e.getMessage())
.path(getCurrentPath())
.details(Map.of(
"serviceName", e.getServiceName(),
"operation", e.getOperation(),
"remoteError", e.getRemoteError()
))
.build();
return ResponseEntity.status(status).body(error);
}
// Handle circuit breaker exceptions
@ExceptionHandler({CallNotPermittedException.class, TimeoutException.class})
public ResponseEntity<ErrorResponse> handleCircuitBreakerException(Exception e) {
log.error("Circuit breaker exception", e);
ErrorResponse error = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.SERVICE_UNAVAILABLE.value())
.error("Service Unavailable")
.message("Service is temporarily unavailable")
.path(getCurrentPath())
.build();
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(error);
}
// Handle service unavailable
@ExceptionHandler(ServiceUnavailableException.class)
public ResponseEntity<ErrorResponse> handleServiceUnavailable(ServiceUnavailableException e) {
log.error("Service unavailable", e);
ErrorResponse error = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.SERVICE_UNAVAILABLE.value())
.error("Service Unavailable")
.message(e.getMessage())
.path(getCurrentPath())
.build();
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(error);
}
private HttpStatus mapRemoteErrorStatus(ErrorResponse remoteError) {
if (remoteError == null) {
return HttpStatus.INTERNAL_SERVER_ERROR;
}
// Map remote status codes to appropriate HTTP status
switch (remoteError.getStatus()) {
case 400: return HttpStatus.BAD_REQUEST;
case 404: return HttpStatus.NOT_FOUND;
case 409: return HttpStatus.CONFLICT;
case 422: return HttpStatus.UNPROCESSABLE_ENTITY;
default: return HttpStatus.INTERNAL_SERVER_ERROR;
}
}
}7. Circuit Breaker Configuration
@Configuration
public class CircuitBreakerConfig {
@Bean
public Customizer<Resilience4JCircuitBreakerFactory> globalCustomConfiguration() {
return factory -> factory.configureDefault(id -> new Resilience4JConfigBuilder(id)
.timeLimiterConfig(TimeLimiterConfig.custom()
.timeoutDuration(Duration.ofSeconds(4))
.build())
.circuitBreakerConfig(io.github.resilience4j.circuitbreaker.CircuitBreakerConfig.custom()
.slidingWindowSize(5)
.permittedNumberOfCallsInHalfOpenState(2)
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofMillis(30000))
.build())
.build());
}
}Best Practices for Exception Handling
1. Use Appropriate Exception Types
- Use checked exceptions for recoverable conditions
- Use unchecked exceptions for programming errors
- Create meaningful custom exceptions
2. Provide Context in Exception Messages
// Bad
throw new IllegalArgumentException("Invalid input");
// Good
throw new IllegalArgumentException("User age must be between 0 and 150, but was: " + age);3. Don't Ignore Exceptions
// Bad
try {
riskyOperation();
} catch (Exception e) {
// Silent failure
}
// Good
try {
riskyOperation();
} catch (Exception e) {
log.error("Failed to perform risky operation", e);
// Handle appropriately
}4. Use Specific Exception Types
// Bad
catch (Exception e) {
// Too broad
}
// Good
catch (IOException e) {
// Handle I/O issues
} catch (SQLException e) {
// Handle database issues
}5. Clean Up Resources
// Use try-with-resources when possible
try (Connection conn = getConnection()) {
// Use connection
} catch (SQLException e) {
// Handle exception
}
// Connection automatically closedAdvanced Exception Handling Patterns
1. Exception Translation Pattern
@Repository
public class UserRepositoryImpl implements UserRepository {
public User findById(String id) throws UserNotFoundException {
try {
// Database operation
return jdbcTemplate.queryForObject(sql, params, mapper);
} catch (EmptyResultDataAccessException e) {
// Translate Spring exception to domain exception
throw new UserNotFoundException(id);
} catch (DataAccessException e) {
// Translate to more specific domain exception
throw new UserDataException("Failed to access user data", e);
}
}
}2. Retry Pattern with Exception Handling
@Service
public class ResilientService {
@Retryable(value = {ServiceCommunicationException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2))
public String callExternalService(String request) {
try {
return externalServiceClient.call(request);
} catch (FeignException e) {
log.warn("External service call failed, attempt will be retried", e);
throw new ServiceCommunicationException("external-service", e.status(), e.getMessage());
}
}
@Recover
public String recoverFromServiceCall(ServiceCommunicationException e, String request) {
log.error("All retry attempts failed for external service call", e);
return "Default response due to service unavailability";
}
}3. Bulk Operation Exception Handling
@Service
public class BulkUserService {
public BulkOperationResult<User> createUsers(List<CreateUserRequest> requests) {
List<User> successfulUsers = new ArrayList<>();
List<BulkOperationError> errors = new ArrayList<>();
for (int i = 0; i < requests.size(); i++) {
try {
User user = userService.createUser(requests.get(i));
successfulUsers.add(user);
} catch (InvalidUserDataException e) {
errors.add(new BulkOperationError(i, e.getField(), e.getMessage()));
} catch (Exception e) {
errors.add(new BulkOperationError(i, "general", "Unexpected error: " + e.getMessage()));
}
}
return BulkOperationResult.<User>builder()
.successful(successfulUsers)
.errors(errors)
.totalProcessed(requests.size())
.successCount(successfulUsers.size())
.errorCount(errors.size())
.build();
}
}
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class BulkOperationResult<T> {
private List<T> successful;
private List<BulkOperationError> errors;
private int totalProcessed;
private int successCount;
private int errorCount;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public class BulkOperationError {
private int index;
private String field;
private String message;
}Monitoring and Logging
1. Structured Logging with Exception Context
@Service
@Slf4j
public class MonitoredService {
public void processOrder(String orderId) {
MDC.put("orderId", orderId);
MDC.put("operation", "processOrder");
try {
log.info("Starting order processing");
// Process order
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
processOrderInternal(order);
log.info("Order processed successfully");
} catch (OrderNotFoundException e) {
log.warn("Order not found: {}", e.getMessage());
throw e;
} catch (PaymentException e) {
log.error("Payment processing failed", e);
throw e;
} catch (Exception e) {
log.error("Unexpected error during order processing", e);
throw new OrderProcessingException("Failed to process order", e);
} finally {
MDC.clear();
}
}
}2. Custom Exception Metrics
@Component
public class ExceptionMetrics {
private final MeterRegistry meterRegistry;
private final Counter exceptionCounter;
public ExceptionMetrics(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.exceptionCounter = Counter.builder("application.exceptions")
.description("Count of application exceptions")
.register(meterRegistry);
}
@EventListener
public void handleException(ExceptionEvent event) {
exceptionCounter.increment(
Tags.of(
"exception.class", event.getException().getClass().getSimpleName(),
"service", event.getServiceName(),
"operation", event.getOperation()
)
);
}
}
// Custom exception event
@Data
@AllArgsConstructor
public class ExceptionEvent {
private Exception exception;
private String serviceName;
private String operation;
private LocalDateTime timestamp;
}Testing Exception Handling
1. Unit Testing Exceptions
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@Test
void shouldThrowUserNotFoundExceptionWhenUserDoesNotExist() {
// Given
String userId = "nonexistent";
when(userRepository.findById(userId)).thenReturn(null);
// When & Then
UserNotFoundException exception = assertThrows(
UserNotFoundException.class,
() -> userService.findUserById(userId)
);
assertEquals("User not found with ID: " + userId, exception.getMessage());
assertEquals(userId, exception.getUserId());
}
@Test
void shouldThrowInvalidUserDataExceptionForInvalidEmail() {
// Given
CreateUserRequest request = new CreateUserRequest();
request.setEmail("invalid-email");
request.setAge(25);
// When & Then
InvalidUserDataException exception = assertThrows(
InvalidUserDataException.class,
() -> userService.createUser(request)
);
assertEquals("email", exception.getField());
assertEquals("invalid-email", exception.getValue());
assertThat(exception.getMessage()).contains("Invalid email format");
}
@Test
void shouldHandleDatabaseExceptionGracefully() {
// Given
String userId = "user123";
when(userRepository.findById(userId)).thenThrow(new DataAccessException("DB connection failed") {});
// When & Then
assertThrows(UserDataException.class, () -> userService.findUserById(userId));
}
}2. Integration Testing with TestContainers
@SpringBootTest
@Testcontainers
class UserControllerIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:13")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private TestEntityManager entityManager;
@Test
void shouldReturn404WhenUserNotFound() {
// When
ResponseEntity<ErrorResponse> response = restTemplate.getForEntity(
"/api/users/nonexistent",
ErrorResponse.class
);
// Then
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
ErrorResponse error = response.getBody();
assertNotNull(error);
assertEquals(404, error.getStatus());
assertEquals("User Not Found", error.getError());
assertThat(error.getMessage()).contains("User not found with ID: nonexistent");
}
@Test
void shouldReturn400ForInvalidUserData() {
// Given
CreateUserRequest request = new CreateUserRequest();
request.setEmail("invalid");
request.setAge(-5);
// When
ResponseEntity<ErrorResponse> response = restTemplate.postForEntity(
"/api/users",
request,
ErrorResponse.class
);
// Then
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
ErrorResponse error = response.getBody();
assertNotNull(error);
assertEquals(400, error.getStatus());
assertNotNull(error.getDetails());
}
}3. Testing Feign Client Exception Handling
@SpringBootTest
class UserServiceClientTest {
@MockBean
private UserServiceClient userServiceClient;
@Autowired
private OrderService orderService;
@Test
void shouldHandleUserServiceNotFound() {
// Given
String userId = "nonexistent";
CreateOrderRequest request = new CreateOrderRequest();
request.setUserId(userId);
RemoteServiceException remoteException = new RemoteServiceException(
"UserService",
"getUser",
ErrorResponse.builder()
.status(404)
.error("User Not Found")
.message("User not found with ID: " + userId)
.build()
);
when(userServiceClient.getUser(userId)).thenThrow(remoteException);
// When & Then
UserNotFoundException exception = assertThrows(
UserNotFoundException.class,
() -> orderService.createOrder(request)
);
assertEquals(userId, exception.getUserId());
}
@Test
void shouldHandleServiceCommunicationFailure() {
// Given
String userId = "user123";
CreateOrderRequest request = new CreateOrderRequest();
request.setUserId(userId);
ServiceCommunicationException commException = new ServiceCommunicationException(
"UserService",
503,
"Service temporarily unavailable"
);
when(userServiceClient.getUser(userId)).thenThrow(commException);
// When & Then
ServiceUnavailableException exception = assertThrows(
ServiceUnavailableException.class,
() -> orderService.createOrder(request)
);
assertThat(exception.getMessage()).contains("User service is currently unavailable");
}
}Configuration Examples
1. Application Properties for Exception Handling
# application.yml
spring:
application:
name: order-service
# Feign configuration
feign:
client:
config:
default:
connectTimeout: 5000
readTimeout: 5000
loggerLevel: basic
errorDecoder: com.example.config.CustomFeignErrorDecoder
retryer: com.example.config.CustomRetryer
# Circuit breaker configuration
resilience4j:
circuitbreaker:
configs:
default:
slidingWindowSize: 10
permittedNumberOfCallsInHalfOpenState: 3
failureRateThreshold: 50
waitDurationInOpenState: 10000ms
slowCallRateThreshold: 50
slowCallDurationThreshold: 2000ms
instances:
user-service:
baseConfig: default
retry:
configs:
default:
maxAttempts: 3
waitDuration: 1000ms
retryExceptions:
- com.example.exception.ServiceCommunicationException
- java.net.ConnectException
ignoreExceptions:
- com.example.exception.UserNotFoundException
# Logging configuration
logging:
level:
com.example: DEBUG
feign: DEBUG
pattern:
console: "%d{HH:mm:ss.SSS} [%thread] %-5level [%X{correlationId}] %logger{36} - %msg%n"
# Monitoring
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
metrics:
export:
prometheus:
enabled: trueConclusion
Exception handling is a crucial aspect of building robust Java and Spring Boot applications, especially in microservice architectures. Key takeaways from this comprehensive guide:
Core Principles:
- Use appropriate exception types — Choose between checked and unchecked exceptions based on the use case
- Provide meaningful context — Include relevant information in exception messages and custom exception classes
- Handle exceptions at the right level — Use global exception handlers for cross-cutting concerns and specific handlers for business logic
- Design for resilience — Implement circuit breakers, retries, and graceful degradation for distributed systems
Microservice Considerations:
- Service boundaries — Handle exceptions properly when crossing service boundaries with Feign clients
- Error translation — Convert technical exceptions to meaningful business exceptions
- Monitoring and observability — Implement proper logging, metrics, and tracing for distributed exception handling
- Resilience patterns — Use circuit breakers, bulkheads, and timeouts to handle service failures gracefully
Testing Strategy:
- Unit tests — Test exception scenarios thoroughly with proper assertions
- Integration tests — Verify end-to-end exception handling behavior
- Contract testing — Ensure service interfaces handle exceptions consistently
By following these patterns and practices, you can build applications that gracefully handle failures, provide meaningful error messages to users, and maintain system stability even when things go wrong. Remember that good exception handling is not just about catching errors — it's about creating a robust, maintainable, and user-friendly system that can recover from failures and provide clear feedback about what went wrong.
The key is to balance comprehensive error handling with code maintainability, ensuring that your exception handling strategy supports both developers debugging issues and users understanding what actions they need to take.