Skip to content

Java – Exception Handling

Exception handling is one of the most commonly done wrong things in Java codebases. Silent swallowing, catching Exception, and printing stack traces instead of logging are all bugs — they hide failures and make production incidents impossible to diagnose. These standards define how exceptions are thrown, caught, wrapped, logged, and structured at Cygnus Dynamics.

Core Rules

  • Always catch the most specific exception type available — never catch (Exception e) or catch (Throwable t)
  • Never swallow exceptions silently — an empty catch block is a bug
  • Include meaningful context in exception messages — the ID, the value, the operation that failed
  • Use custom domain exceptions for all business-rule violations
  • Never use exceptions for flow control — do not throw an exception to signal a normal condition
  • Log exceptions once — at the point where you handle them, not at every rethrow
  • Always use try-with-resources for anything that implements AutoCloseable
  • Preserve the original cause when wrapping exceptions

Checked vs Unchecked Exceptions

Java has two categories of exceptions. Know when to use each.

Type Extends When to use
Checked Exception External failures the caller can reasonably recover from (e.g. file not found, network timeout)
Unchecked RuntimeException Programming errors, violated preconditions, or business rule failures where the caller cannot meaningfully recover

At Cygnus Dynamics, all custom domain exceptions are unchecked (RuntimeException). Checked exceptions create noise in service layers and force callers to catch exceptions they cannot handle.

// ✅ Correct — unchecked domain exceptions
public class OrderNotFoundException extends RuntimeException {
    public OrderNotFoundException(Long orderId) {
        super("Order not found with id: " + orderId);
    }
}

public class InsufficientFundsException extends RuntimeException {
    public InsufficientFundsException(Long accountId, BigDecimal required, BigDecimal available) {
        super(String.format(
            "Insufficient funds on account %d: required %.2f, available %.2f",
            accountId, required, available
        ));
    }
}

public class PaymentFailedException extends RuntimeException {
    public PaymentFailedException(String reason, Throwable cause) {
        super("Payment processing failed: " + reason, cause);
    }
}

Custom Exception Hierarchy

Structure custom exceptions in a hierarchy that allows callers to catch at the right level of granularity.

// ✅ Base exception for the domain — callers can catch broadly or specifically
public class CygnusException extends RuntimeException {
    public CygnusException(String message) {
        super(message);
    }
    public CygnusException(String message, Throwable cause) {
        super(message, cause);
    }
}

// Domain-level groupings
public class OrderException extends CygnusException {
    public OrderException(String message) { super(message); }
    public OrderException(String message, Throwable cause) { super(message, cause); }
}

public class PaymentException extends CygnusException {
    public PaymentException(String message) { super(message); }
    public PaymentException(String message, Throwable cause) { super(message, cause); }
}

// Specific exceptions
public class OrderNotFoundException extends OrderException {
    public OrderNotFoundException(Long orderId) {
        super("Order not found with id: " + orderId);
    }
}

public class OrderAlreadyCancelledException extends OrderException {
    public OrderAlreadyCancelledException(Long orderId) {
        super("Order " + orderId + " has already been cancelled and cannot be modified");
    }
}

public class CardDeclinedException extends PaymentException {
    private final String declineCode;

    public CardDeclinedException(String declineCode) {
        super("Card declined with code: " + declineCode);
        this.declineCode = declineCode;
    }

    public String getDeclineCode() {
        return declineCode;
    }
}

This hierarchy lets callers choose precision:

// Catch a specific condition
catch (OrderNotFoundException e) { return ResponseEntity.notFound().build(); }

// Catch any order-related error
catch (OrderException e) { return ResponseEntity.badRequest().body(e.getMessage()); }

// Catch any application error
catch (CygnusException e) { return ResponseEntity.internalServerError().build(); }

Catching Exceptions

Always catch the most specific type

// ✅ Correct — catches only what is expected
try {
    return userRepository.findById(userId);
} catch (DataAccessException e) {
    log.error("Database error fetching user {}", userId, e);
    throw new UserDataException("Failed to retrieve user: " + userId, e);
}

// ❌ Incorrect — catches everything including OutOfMemoryError, NullPointerException
try {
    return userRepository.findById(userId);
} catch (Exception e) {
    e.printStackTrace();  // wrong on every level — see logging section
    return null;
}

Never swallow exceptions

An empty catch block silently hides failures. This is one of the most dangerous patterns in Java.

// ❌ Never do this — the exception disappears without a trace
try {
    inventoryService.reserveStock(item);
} catch (Exception e) {
    // nothing — inventory reservation silently failed
}

// ❌ Also wrong — stack trace goes to stderr, not the logging system
try {
    inventoryService.reserveStock(item);
} catch (Exception e) {
    e.printStackTrace();
}

// ✅ Correct — handle explicitly or propagate with context
try {
    inventoryService.reserveStock(item);
} catch (InsufficientStockException e) {
    log.warn("Stock reservation failed for item {}: {}", item.getId(), e.getMessage());
    throw e;  // propagate — the caller must handle this
} catch (InventoryServiceException e) {
    log.error("Inventory service error reserving item {}", item.getId(), e);
    throw new OrderProcessingException("Could not reserve stock for order", e);
}

Multi-catch for identical handling

When two exceptions require exactly the same handling, use multi-catch to avoid duplication.

// ✅ Correct — multi-catch when handling is identical
try {
    externalApi.call(request);
} catch (TimeoutException | ConnectionRefusedException e) {
    log.warn("External API unavailable: {}", e.getMessage());
    throw new ServiceUnavailableException("Payment provider temporarily unavailable", e);
}

Wrapping Exceptions

When catching a low-level exception and rethrowing a domain exception, always preserve the original cause. Losing the cause destroys the stack trace and makes production debugging impossible.

// ✅ Correct — original cause preserved
try {
    return orderRepository.save(order);
} catch (DataIntegrityViolationException e) {
    throw new DuplicateOrderException(
        "An order with reference " + order.getReference() + " already exists", e  // ← cause preserved
    );
}

// ❌ Incorrect — cause lost, original stack trace gone
try {
    return orderRepository.save(order);
} catch (DataIntegrityViolationException e) {
    throw new DuplicateOrderException("Duplicate order");  // ← no cause — debugging is now much harder
}

Logging Exceptions

Log an exception once — at the point where you handle it and stop propagating it. If you are rethrowing, do not log — let the handler log.

// ✅ Correct — log only at the point of final handling
// In the service — rethrow without logging
public Order getOrder(Long id) {
    return orderRepository.findById(id)
        .orElseThrow(() -> new OrderNotFoundException(id));  // no logging here
}

// In the global exception handler — log once
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException e) {
    log.warn("Order not found: {}", e.getMessage());
    return ResponseEntity.status(HttpStatus.NOT_FOUND)
        .body(new ErrorResponse(e.getMessage()));
}

// ❌ Incorrect — logged at every rethrow, same exception logged 3 times
public Order getOrder(Long id) {
    try {
        return orderRepository.findById(id)
            .orElseThrow(() -> new OrderNotFoundException(id));
    } catch (OrderNotFoundException e) {
        log.error("Order not found", e);  // ← log 1
        throw e;
    }
}

public Order processOrder(Long id) {
    try {
        return getOrder(id);
    } catch (OrderNotFoundException e) {
        log.error("Order not found", e);  // ← log 2 — same exception
        throw e;
    }
}

Log levels

Level When to use
log.error(...) Unexpected failures that require immediate attention — system errors, data corruption risk
log.warn(...) Expected but notable conditions — business rule violations, retries, degraded operation
log.info(...) Normal business events — order created, payment confirmed
log.debug(...) Detailed diagnostic information — input values, computed results (never in production builds)
// ✅ Correct log levels
log.error("Failed to connect to payment gateway after {} retries", MAX_RETRIES, e);
log.warn("Card declined for order {}: {}", orderId, declineCode);
log.info("Order {} confirmed for user {}", order.getId(), user.getId());
log.debug("Calculated discount: {} on subtotal: {}", discount, subtotal);

// ❌ Incorrect — wrong levels
log.info("NullPointerException in payment service", e);   // error disguised as info
log.error("User {} logged in", userId);                   // routine event at error level

Never log sensitive data

// ❌ Never log passwords, card numbers, tokens, or PII
log.info("Processing payment for card: {}", cardNumber);
log.debug("User authenticated with password: {}", password);

// ✅ Log only non-sensitive identifiers
log.info("Processing payment for order: {}", orderId);
log.debug("User {} authenticated successfully", userId);

Try-With-Resources

Always use try-with-resources for anything that implements AutoCloseable. Never close resources manually — they will not be closed if an exception is thrown before the close call.

// ✅ Correct — resource is always closed, even if an exception is thrown
public String readFile(Path path) throws IOException {
    try (BufferedReader reader = Files.newBufferedReader(path)) {
        return reader.lines().collect(Collectors.joining("\n"));
    }
}

// ✅ Multiple resources — both closed in reverse order automatically
public void transferFile(Path source, Path destination) throws IOException {
    try (
        InputStream in  = Files.newInputStream(source);
        OutputStream out = Files.newOutputStream(destination)
    ) {
        in.transferTo(out);
    }
}

// ❌ Incorrect — not closed if readLine() throws
public String readFile(Path path) throws IOException {
    BufferedReader reader = Files.newBufferedReader(path);
    String content = reader.lines().collect(Collectors.joining("\n"));
    reader.close();  // never reached if an exception occurs above
    return content;
}

Spring Global Exception Handler

All REST APIs must have a @RestControllerAdvice that maps domain exceptions to HTTP responses. Exception-to-status-code mapping must not be scattered across individual controllers.

// ✅ Correct — centralised exception-to-HTTP mapping
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(OrderNotFoundException e) {
        log.warn("Resource not found: {}", e.getMessage());
        return ResponseEntity
            .status(HttpStatus.NOT_FOUND)
            .body(new ErrorResponse("NOT_FOUND", e.getMessage()));
    }

    @ExceptionHandler(ValidationException.class)
    public ResponseEntity<ErrorResponse> handleValidation(ValidationException e) {
        log.warn("Validation failed: {}", e.getMessage());
        return ResponseEntity
            .status(HttpStatus.BAD_REQUEST)
            .body(new ErrorResponse("VALIDATION_ERROR", e.getMessage()));
    }

    @ExceptionHandler(CardDeclinedException.class)
    public ResponseEntity<ErrorResponse> handleCardDeclined(CardDeclinedException e) {
        log.warn("Card declined with code: {}", e.getDeclineCode());
        return ResponseEntity
            .status(HttpStatus.PAYMENT_REQUIRED)
            .body(new ErrorResponse("CARD_DECLINED", "Your card was declined. Please try another payment method."));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) {
        log.error("Unexpected error", e);
        return ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred. Please try again."));
    }
}

// Standard error response shape
public record ErrorResponse(String code, String message) { }

Never expose internal details in error responses

Error messages returned to API clients must never include stack traces, SQL queries, internal class names, or system paths. Log the full detail internally; return only a safe, user-facing message externally.

Exceptions for Flow Control — Never

Throwing an exception to handle a normal, expected condition is a misuse of the mechanism. Exceptions are for abnormal situations.

// ❌ Incorrect — exception used to check existence
public boolean userExists(Long userId) {
    try {
        userRepository.findById(userId).orElseThrow();
        return true;
    } catch (NoSuchElementException e) {
        return false;
    }
}

// ✅ Correct — use a purpose-built query
public boolean userExists(Long userId) {
    return userRepository.existsById(userId);
}
// ❌ Incorrect — exception used to exit a loop
try {
    while (true) {
        processNext();
    }
} catch (NoMoreItemsException e) {
    // done
}

// ✅ Correct — use a condition
while (iterator.hasNext()) {
    processNext(iterator.next());
}

Quick Reference — What to Do and What Not to Do

Situation Do Don't
Resource is not found Throw XxxNotFoundException with the ID Return null
Business rule violated Throw a specific domain exception Return false or -1
External service fails Wrap in a domain exception, preserve cause Catch Exception, print stack trace
Same exception at every rethrow Log once at the final handler Log at every catch and rethrow
Need to check existence Use repository.existsById() Throw and catch an exception
Multiple exceptions, same handling Use multi-catch catch (A \| B e) Duplicate catch blocks
Closeable resource Use try-with-resources Call close() manually