Exception Hierarchy Strategy
Organize exceptions in a meaningful hierarchy that reflects your domain.
// Base exception for your application
public class ApplicationException extends RuntimeException {
private final ErrorCode errorCode;
public ApplicationException(ErrorCode code, String message) {
super(message);
this.errorCode = code;
}
public ApplicationException(ErrorCode code, String message, Throwable cause) {
super(message, cause);
this.errorCode = code;
}
public ErrorCode getErrorCode() {
return errorCode;
}
}
// Domain-specific exceptions
public class EntityNotFoundException extends ApplicationException {
public EntityNotFoundException(String entityType, Object id) {
super(ErrorCode.NOT_FOUND,
String.format("%s not found with id: %s", entityType, id));
}
}
public class ValidationException extends ApplicationException {
private final List<FieldError> fieldErrors;
public ValidationException(List<FieldError> errors) {
super(ErrorCode.VALIDATION_FAILED, "Validation failed");
this.fieldErrors = errors;
}
}
public class BusinessRuleException extends ApplicationException {
public BusinessRuleException(String message) {
super(ErrorCode.BUSINESS_RULE_VIOLATION, message);
}
}
Error Codes
public enum ErrorCode {
NOT_FOUND("E001", 404),
VALIDATION_FAILED("E002", 400),
BUSINESS_RULE_VIOLATION("E003", 422),
UNAUTHORIZED("E004", 401),
INTERNAL_ERROR("E999", 500);
private final String code;
private final int httpStatus;
ErrorCode(String code, int httpStatus) {
this.code = code;
this.httpStatus = httpStatus;
}
}
Try-with-Resources Pattern
// Always use try-with-resources for AutoCloseable resources
public List<String> readLines(Path path) throws IOException {
try (BufferedReader reader = Files.newBufferedReader(path)) {
return reader.lines().collect(Collectors.toList());
}
// Resource automatically closed, even on exception
}
// Multiple resources
public void copyData(Path source, Path target) throws IOException {
try (
InputStream in = Files.newInputStream(source);
OutputStream out = Files.newOutputStream(target)
) {
in.transferTo(out);
}
}
// Custom AutoCloseable
public class DatabaseConnection implements AutoCloseable {
private final Connection connection;
@Override
public void close() {
try {
connection.close();
} catch (SQLException e) {
// Log but don't rethrow in close()
log.warn("Failed to close connection", e);
}
}
}
Fail-Fast Principle
// Validate early, fail fast
public class OrderService {
public Order createOrder(OrderRequest request) {
// Validate at the entry point
validateRequest(request);
validateBusinessRules(request);
// If we get here, we know the request is valid
return processOrder(request);
}
private void validateRequest(OrderRequest request) {
Objects.requireNonNull(request, "Request cannot be null");
Objects.requireNonNull(request.getCustomerId(), "Customer ID required");
if (request.getItems().isEmpty()) {
throw new ValidationException("Order must have at least one item");
}
}
private void validateBusinessRules(OrderRequest request) {
Customer customer = customerRepository.findById(request.getCustomerId())
.orElseThrow(() -> new EntityNotFoundException("Customer", request.getCustomerId()));
if (customer.hasOutstandingDebt()) {
throw new BusinessRuleException("Cannot create order: customer has outstanding debt");
}
}
}
Optional for Nullable Returns
// BAD: Returns null
public User findByEmail(String email) {
return userMap.get(email); // Could be null!
}
// GOOD: Returns Optional
public Optional<User> findByEmail(String email) {
return Optional.ofNullable(userMap.get(email));
}
// Using Optional effectively
public UserDto getUserProfile(String email) {
return userRepository.findByEmail(email)
.map(this::toDto)
.orElseThrow(() -> new EntityNotFoundException("User", email));
}
// Optional with default value
public User getOrCreateGuest(String email) {
return userRepository.findByEmail(email)
.orElseGet(() -> createGuestUser(email));
}
// DON'T use Optional for:
// - Fields (use null instead)
// - Method parameters (overload or use @Nullable)
// - Collections (return empty collection)
Global Exception Handling (Spring)
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(EntityNotFoundException ex) {
log.debug("Entity not found: {}", ex.getMessage());
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse(ex.getErrorCode(), ex.getMessage()));
}
@ExceptionHandler(ValidationException.class)
public ResponseEntity<ErrorResponse> handleValidation(ValidationException ex) {
log.debug("Validation failed: {}", ex.getFieldErrors());
ErrorResponse response = new ErrorResponse(
ex.getErrorCode(),
ex.getMessage(),
ex.getFieldErrors()
);
return ResponseEntity.badRequest().body(response);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
// Log full stack trace for unexpected errors
log.error("Unexpected error", ex);
// Don't expose internal details to client
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse(
ErrorCode.INTERNAL_ERROR,
"An unexpected error occurred"
));
}
}
// Standardized error response
public record ErrorResponse(
String code,
String message,
Instant timestamp,
List<FieldError> fieldErrors
) {
public ErrorResponse(ErrorCode code, String message) {
this(code.getCode(), message, Instant.now(), List.of());
}
}
Logging Best Practices
public class PaymentService {
private static final Logger log = LoggerFactory.getLogger(PaymentService.class);
public void processPayment(Payment payment) {
log.info("Processing payment: id={}, amount={}",
payment.getId(), payment.getAmount());
try {
gateway.process(payment);
log.info("Payment processed successfully: id={}", payment.getId());
} catch (GatewayTimeoutException e) {
// Recoverable - log as warning
log.warn("Payment gateway timeout, will retry: id={}", payment.getId());
throw new RetryableException(e);
} catch (PaymentDeclinedException e) {
// Expected business case - log as info or debug
log.info("Payment declined: id={}, reason={}",
payment.getId(), e.getReason());
throw e;
} catch (Exception e) {
// Unexpected - log as error with stack trace
log.error("Unexpected error processing payment: id={}",
payment.getId(), e);
throw new PaymentException("Failed to process payment", e);
}
}
}
Logging Levels
- ERROR - Application failure, needs attention
- WARN - Potential problem, recoverable
- INFO - Important business events
- DEBUG - Detailed diagnostic info
- TRACE - Very detailed, typically for debugging
Exception Translation
// Translate low-level exceptions to domain exceptions
public class UserRepository {
public User save(User user) {
try {
return jdbcTemplate.update(sql, user.getName(), user.getEmail());
} catch (DuplicateKeyException e) {
// Translate to domain exception
throw new UserAlreadyExistsException(user.getEmail(), e);
} catch (DataAccessException e) {
// Wrap infrastructure exception
throw new RepositoryException("Failed to save user", e);
}
}
}
// Preserve the cause chain
public class UserAlreadyExistsException extends ApplicationException {
public UserAlreadyExistsException(String email, Throwable cause) {
super(ErrorCode.DUPLICATE_ENTITY,
"User already exists with email: " + email,
cause); // Important: preserve the cause!
}
}
Anti-Patterns to Avoid
// BAD: Empty catch block
try {
riskyOperation();
} catch (Exception e) {
// Silently swallowed!
}
// BAD: Catching Throwable or Error
try {
operation();
} catch (Throwable t) {
// Catches OutOfMemoryError, StackOverflowError, etc.
}
// BAD: Using exceptions for flow control
try {
int value = Integer.parseInt(input);
} catch (NumberFormatException e) {
value = 0; // Use validation instead!
}
// GOOD: Validate first
if (input.matches("\\d+")) {
value = Integer.parseInt(input);
} else {
value = 0;
}
// BAD: Log and rethrow
try {
operation();
} catch (Exception e) {
log.error("Error", e);
throw e; // Logs twice if caller also logs!
}
// BAD: throw new Exception()
throw new Exception("Something went wrong"); // Too generic!
// GOOD: Use specific exceptions
throw new InvalidOrderStateException(order.getId(), order.getStatus());