How a production app can be crashed at 2 AM because of this single line:

try {
    processPayment(order);
} catch (Exception e) {
    // TODO: Handle this later
}

That "TODO" can cost a company $50,000 in lost transactions. If the payment service goes down, our code will silently swallow every exception. No logs. No alerts. Just a silent failure.

I learned exception handling the hard way. Today, I'll show you what I wish someone had taught me on day one.

The Mistakes Almost Everyone Makes

Mistake #1: Catching Exception (The Cardinal Sin)

// ❌ WRONG - Catches absolutely everything
try {
    String result = fetchDataFromAPI();
    processData(result);
    saveToDatabase(result);
} catch (Exception e) {
    System.out.println("Something went wrong");
    return null;
}

Why This Is Terrible:

  • Catches NullPointerException,RuntimeException, CheckedException, everything
  • Masks bugs that should crash your app
  • Makes debugging impossible (which line failed?)
  • Exception includes things you should NEVER catch

What Senior Devs Do Instead:

// ✅ CORRECT - Catch specific exceptions
try {
    String result = fetchDataFromAPI();
    processData(result);
    saveToDatabase(result);
} catch (ApiConnectionException e) {
    logger.error("API connection failed: {}", e.getMessage());
    throw new ServiceUnavailableException("External service unavailable", e);
} catch (DataValidationException e) {
    logger.warn("Invalid data received: {}", e.getMessage());
    throw new BadRequestException("Invalid data format", e);
} catch (DatabaseException e) {
    logger.error("Database operation failed: {}", e.getMessage());
    throw new InternalServerException("Failed to save data", e);
}

The Rule: Only catch exceptions you can actually handle. Everything else should propagate up.

Mistake #2: Swallowing Exceptions

// ❌ WRONG - Exception disappears into the void
try {
    updateUserProfile(userId, data);
} catch (Exception e) {
    // Silent failure - nobody knows this happened
}

This is worse than a crash. At least crashes get noticed.

What Senior Devs Do Instead:

// ✅ CORRECT - Always log, always propagate
try {
    updateUserProfile(userId, data);
} catch (ValidationException e) {
    logger.warn("User profile update failed for userId={}: {}", 
                userId, e.getMessage());
    throw e; // Re-throw so caller knows it failed
} catch (DatabaseException e) {
    logger.error("Database error updating userId={}", userId, e);
    // Include the original exception as cause
    throw new ServiceException("Failed to update profile", e);
}

The Rule: Every exception must be either logged with context OR propagated. Never both silent and swallowed.

Mistake #3: Using Exceptions for Control Flow

// ❌ WRONG - Exceptions aren't if-statements
public User findUser(Long id) {
    try {
        return userRepository.findById(id).get();
    } catch (NoSuchElementException e) {
        return null; // Using exception to handle "not found"
    }
}

Problems:

  • Exceptions are expensive (stack traces, unwinding)
  • Misleading — "not found" isn't exceptional, it's expected
  • Harder to read than simple conditionals

What Senior Devs Do Instead:

// ✅ CORRECT - Use Optional for expected scenarios
public Optional<User> findUser(Long id) {
    return userRepository.findById(id);
}

// Usage
Optional<User> user = findUser(123L);
if (user.isPresent()) {
    // Handle found case
} else {
    // Handle not found case
}

// Or with modern Optional patterns
return findUser(123L)
    .map(this::processUser)
    .orElseThrow(() -> new UserNotFoundException(id));

The Rule: If it's expected (like "not found"), don't use exceptions. Use Optional, return codes, or nullable types.

Mistake #4: Losing the Original Exception

// ❌ WRONG - Stack trace disappears
try {
    processOrder(order);
} catch (PaymentException e) {
    throw new OrderException("Order processing failed");
    // Where did the PaymentException go?
}

When this fails in production, you'll only see "Order processing failed" with no clue about the payment issue.

What Senior Devs Do Instead:

// ✅ CORRECT - Preserve the exception chain
try {
    processOrder(order);
} catch (PaymentException e) {
    logger.error("Payment failed for orderId={}", order.getId(), e);
    throw new OrderException(
        "Order processing failed: " + e.getMessage(), 
        e  // ← This preserves the entire stack trace
    );
}

The Rule: Always pass the original exception as a cause. Future-you, debugging at 3 AM, will thank present-you.

Mistake #5: Checked Exceptions Everywhere

// ❌ WRONG - Forces every caller to handle
public void saveUser(User user) throws DatabaseException, 
                                        ValidationException, 
                                        NetworkException {
    // Now every method that calls this needs try-catch
}

Checked exceptions made sense in 1995. In modern Java, they're often just ceremony.

What Senior Devs Do Instead:

// ✅ CORRECT - Use unchecked for programming errors
public class UserServiceException extends RuntimeException {
    public UserServiceException(String message, Throwable cause) {
        super(message, cause);
    }
}

public void saveUser(User user) {
    try {
        validateUser(user);
        userRepository.save(user);
    } catch (Exception e) {
        throw new UserServiceException(
            "Failed to save user: " + user.getEmail(), 
            e
        );
    }
}

When to Use Each:

  • Checked exceptions: Recoverable conditions the caller MUST handle (file not found, network timeout)
  • Unchecked exceptions: Programming errors or conditions the caller can't reasonably recover from

The Senior Developer Pattern: Structured Exception Handling

Here's how experienced developers actually structure exception handling:

@Service
public class OrderService {
    
    private static final Logger logger = LoggerFactory.getLogger(OrderService.class);
    
    public OrderResult processOrder(OrderRequest request) {
        try {
            // 1. Validate input
            validateOrderRequest(request);
            
            // 2. Execute business logic
            Payment payment = paymentService.processPayment(request.getPaymentInfo());
            Order order = createOrder(request, payment);
            
            // 3. Persist
            orderRepository.save(order);
            
            // 4. Publish event
            eventPublisher.publish(new OrderCreatedEvent(order));
            
            logger.info("Order processed successfully: orderId={}", order.getId());
            return OrderResult.success(order);
            
        } catch (ValidationException e) {
            logger.warn("Order validation failed: {}", e.getMessage());
            return OrderResult.failure("Invalid order data: " + e.getMessage());
            
        } catch (PaymentException e) {
            logger.error("Payment processing failed for amount={}", 
                        request.getAmount(), e);
            return OrderResult.failure("Payment failed: " + e.getMessage());
            
        } catch (Exception e) {
            logger.error("Unexpected error processing order", e);
            return OrderResult.failure("Order processing failed");
        }
    }
}

What Makes This Good:

  1. Specific exceptions first — Most specific to least specific
  2. Rich logging — Context (orderId, amount) included
  3. Result objects — No exceptions thrown to the controller
  4. Graceful degradation — Unexpected errors are caught but logged

The Controller Layer: Where Exceptions Become HTTP Responses

@RestController
public class OrderController {
    
    @PostMapping("/orders")
    public ResponseEntity<OrderResponse> createOrder(
            @Valid @RequestBody OrderRequest request) {
        
        OrderResult result = orderService.processOrder(request);
        
        if (result.isSuccess()) {
            return ResponseEntity
                .status(HttpStatus.CREATED)
                .body(new OrderResponse(result.getOrder()));
        }
        
        // Map business failures to appropriate HTTP status
        return ResponseEntity
            .badRequest()
            .body(new ErrorResponse(result.getErrorMessage()));
    }
}

// Global exception handler for unexpected exceptions
@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(ValidationException.class)
    public ResponseEntity<ErrorResponse> handleValidation(ValidationException e) {
        return ResponseEntity
            .badRequest()
            .body(new ErrorResponse(e.getMessage()));
    }
    
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) {
        logger.error("Unexpected error", e);
        return ResponseEntity
            .internalServerError()
            .body(new ErrorResponse("An unexpected error occurred"));
    }
}

The Mental Model That Changed Everything

Senior developers think about exceptions in layers:

Layer 1: Domain Logic — Throw specific, meaningful exceptions

Layer 2: Service Layer — Catch, log with context, wrap if needed

Layer 3: Controller/API — Convert to appropriate responses (HTTP status, error messages)

Layer 4: Global Handler — Safety net for truly unexpected exceptions

Each layer has a clear responsibility. Exceptions flow up until someone can meaningfully handle them.

Your Action Plan for Tomorrow

  1. Find one catch (Exception e) in your codebase and make it specific
  2. Add logging context to three catch blocks (include IDs, amounts, timestamps)
  3. Replace one exception-based "not found" with Optional
  4. Review your custom exceptions — are they checked when they should be unchecked?

You don't need to refactor everything at once. Start small. Each improvement makes debugging easier and your code more reliable.

The Bottom Line

Exception handling isn't about writing more try-catch blocks. It's about:

  • Failing explicitly instead of silently
  • Logging context so debugging is possible
  • Using the right tools (Optional, Result objects, specific exceptions)
  • Designing clear responsibility layers in your architecture

The next time production breaks at 2 AM, you'll know exactly what failed and why. That's the difference between junior exception handling and senior exception handling.

What's the worst exception handling bug you've encountered? Share in the comments — I'd love to hear your war stories.