Loading learning content...
After understanding the mechanics and trade-offs of checked and unchecked exceptions, and seeing how different languages approach error handling, we arrive at the most practical question: How do you decide which type to use in your own code?
This isn't an academic exercise. Every exception you create represents a decision about where responsibility for error handling lies, how your API will evolve, and what your callers' experience will be. Poor choices accumulate into codebases that are either fragile (too few exceptions) or tedious (too many forced handlers).
This page synthesizes everything we've learned into a principled decision framework for choosing exception types.
By the end of this page, you will have a clear decision framework for choosing exception types, understand the factors that influence the decision, know how to design exception hierarchies, and be able to apply these principles to real-world scenarios.
Before diving into specific scenarios, let's establish a foundational framework. The central question is: Can the caller reasonably be expected to recover from this condition, and is forcing that recovery consideration at compile time worth the costs?
This question has three components:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
/* * THE EXCEPTION TYPE DECISION TREE * * Start here for every exception you consider throwing: * * 1. Is this a PROGRAMMING ERROR? * └── YES → UNCHECKED EXCEPTION (IllegalArgumentException, NullPointerException, etc.) * └── NO → Continue to #2 * * 2. Is this UNRECOVERABLE at any level? * └── YES → UNCHECKED EXCEPTION (SystemFailureException, ConfigurationException) * └── NO → Continue to #3 * * 3. Can the IMMEDIATE CALLER recover? * └── NO → Continue to #4 * └── YES → Continue to #5 * * 4. Will this exception PROPAGATE through many layers? * └── YES → UNCHECKED EXCEPTION (cleaner intermediate code) * └── NO → Continue to #5 * * 5. Is the exception part of a PUBLIC API that needs stability? * └── YES → Consider impact of API evolution, then continue * └── NO → Continue to #6 * * 6. Are the BENEFITS of compile-time checking worth the COSTS? * Consider: * - Signature pollution through call chain * - Risk of swallowed exceptions if too burdensome * - Documentation alternatives * - Team conventions and preferences * * └── YES → CHECKED EXCEPTION * └── NO → UNCHECKED EXCEPTION */ // EXAMPLE APPLICATIONS OF THE FRAMEWORK public class ExampleDecisions { // ============================================ // DECISION: UNCHECKED - Programming Error // ============================================ /** * Null parameter is a caller bug, not a runtime condition. * Use unchecked exception - caller needs to fix their code, * not "handle" the null. */ public void processOrder(Order order) { Objects.requireNonNull(order, "order cannot be null"); // ... process } /** * Negative quantity is a caller bug. * Invalid arguments indicate programming errors. */ public void setQuantity(int quantity) { if (quantity < 0) { throw new IllegalArgumentException( "quantity cannot be negative: " + quantity); } this.quantity = quantity; } // ============================================ // DECISION: UNCHECKED - Unrecoverable // ============================================ /** * If the database is down at startup, the application * simply cannot run. No recovery is possible. */ public DataSource initializeDatabase() { try { return createDataSource(); } catch (SQLException e) { throw new FatalStartupException( "Cannot connect to database - application cannot start", e); } } // ============================================ // DECISION: UNCHECKED - Deep Propagation // ============================================ /** * Entity not found typically propagates through many layers: * Repository → Service → Facade → Controller * * Only the controller (boundary) can respond meaningfully * (return 404). Intermediate layers just propagate. */ public Order findOrder(String orderId) { Order order = repository.findById(orderId); if (order == null) { throw new OrderNotFoundException(orderId); // Unchecked } return order; } // ============================================ // DECISION: CHECKED - Immediate Recovery Expected // ============================================ /** * User input validation errors are EXPECTED (users make mistakes) * and the immediate caller CAN recover (show error, ask again). * * This is a case where checked exception makes sense. */ public LocalDate parseUserDate(String input) throws InvalidDateFormatException { // Checked try { return LocalDate.parse(input, formatter); } catch (DateTimeParseException e) { throw new InvalidDateFormatException( "Expected format: YYYY-MM-DD", e); } } // Caller handles immediately public void handleDateInput(String input) { try { this.date = parseUserDate(input); moveToNextField(); } catch (InvalidDateFormatException e) { showValidationError(e.getMessage()); // Meaningful recovery } } // ============================================ // DECISION: CHECKED - Safety-Critical Context // ============================================ /** * Financial transactions require careful error handling. * Missing a failure here can result in incorrect balances. * The cost of compile-time checking is worth the safety. */ public TransferReceipt transfer(Account from, Account to, Money amount) throws InsufficientFundsException, TransferLimitExceededException, AccountFrozenException { // All checked validateTransfer(from, to, amount); try { from.debit(amount); to.credit(amount); return createReceipt(from, to, amount); } catch (AccountException e) { // Rollback required - mustn't be missed reverseDebit(from, amount); throw e; } }}Let's examine each decision factor in depth, with concrete guidance on how to evaluate it.
| Factor | Favors Checked | Favors Unchecked |
|---|---|---|
| Error Type | Expected, recoverable runtime condition | Programming bug or unrecoverable failure |
| Recovery Location | Immediate caller can recover | Recovery happens many layers up (or nowhere) |
| Error Frequency | Expected/common (part of normal operation) | Rare/exceptional (shouldn't happen often) |
| API Stability | Set of errors is stable and well-defined | Errors may change as implementation evolves |
| Call Stack Depth | Shallow (1-2 levels to handler) | Deep (many layers just propagate) |
| Safety Criticality | Financial, medical, safety-critical domain | General application logic |
| Functional Programming | Not used with lambdas/streams | Used extensively with FP patterns |
| Team Convention | Team expects/prefers checked | Team finds checked burdensome |
Let's apply our framework to common scenarios you'll encounter, providing clear guidelines for each.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
// ===================================================// SCENARIO: Entity/Resource Not Found// RECOMMENDATION: Unchecked (usually)// =================================================== // RATIONALE:// - Typically propagates to controller/boundary layer// - Intermediate service layers can't do anything meaningful// - Entity absence is often a normal part of operations public class OrderService { // Preferred: Unchecked exception public Order getOrder(String orderId) { return orderRepository.findById(orderId) .orElseThrow(() -> new OrderNotFoundException(orderId)); } // Alternative: Return Optional (no exception) public Optional<Order> findOrder(String orderId) { return orderRepository.findById(orderId); }} // Exception handled at boundary@RestControllerpublic class OrderController { @GetMapping("/orders/{id}") public ResponseEntity<Order> getOrder(@PathVariable String id) { try { return ResponseEntity.ok(orderService.getOrder(id)); } catch (OrderNotFoundException e) { return ResponseEntity.notFound().build(); } }} // ===================================================// SCENARIO: External Service/API Failures// RECOMMENDATION: Unchecked (usually)// =================================================== // RATIONALE:// - Callers typically can't retry (circuit breaker should)// - Failure handling is centralized (exception handler)// - Many layers between call site and meaningful handler public class PaymentGatewayClient { // Unchecked - callers typically can't recover public PaymentResult charge(PaymentRequest request) { try { HttpResponse response = httpClient.post( gatewayUrl, serialize(request) ); return parseResponse(response); } catch (IOException e) { throw new PaymentGatewayException( "Failed to reach payment gateway", e); } }} // ===================================================// SCENARIO: User Input Validation// RECOMMENDATION: Can go either way - depends on context// =================================================== // OPTION A: Checked exception - immediate caller handles// Good when: UI/form context, caller will show validation message public class UserInputValidator { /** * @throws ValidationException caller shows error to user */ public Email parseEmail(String input) throws ValidationException { if (!EMAIL_PATTERN.matcher(input).matches()) { throw new ValidationException("Invalid email format"); } return new Email(input); }} // Immediate caller handlespublic class RegistrationForm { public void onEmailEntered(String email) { try { this.email = validator.parseEmail(email); } catch (ValidationException e) { showFieldError("email", e.getMessage()); // Meaningful recovery } }} // OPTION B: Return validation result (no exception)// Good when: Batch validation, collecting multiple errors public class BulkValidator { public ValidationResult validate(UserInput input) { List<ValidationError> errors = new ArrayList<>(); if (!isValidEmail(input.getEmail())) { errors.add(new ValidationError("email", "Invalid format")); } if (!isValidAge(input.getAge())) { errors.add(new ValidationError("age", "Must be 0-150")); } // ... more validations return new ValidationResult(errors); }} // ===================================================// SCENARIO: Configuration/Initialization Errors// RECOMMENDATION: Unchecked// =================================================== // RATIONALE:// - If config is invalid, application cannot run// - No meaningful recovery - fix the config// - Usually happens at startup, not during operation public class DatabaseConfig { public static DataSource create(Properties props) { String url = props.getProperty("db.url"); if (url == null || url.isBlank()) { throw new ConfigurationException( "db.url is required in configuration"); } try { return new HikariDataSource(buildConfig(props)); } catch (Exception e) { throw new ConfigurationException( "Failed to create database connection", e); } }} // ===================================================// SCENARIO: Business Rule Violations // RECOMMENDATION: Context-dependent// =================================================== // UNCHECKED: When violation indicates bug or unexpected statepublic void cancelOrder(Order order) { if (order.getStatus() == OrderStatus.COMPLETED) { // This shouldn't happen if UI is correct throw new IllegalStateException( "Cannot cancel completed order"); } order.setStatus(OrderStatus.CANCELLED);} // CHECKED: When violation is expected and caller should handlepublic void transfer(Account from, Account to, Money amount) throws InsufficientFundsException { // Insufficient funds is expected operational condition // Caller should handle (e.g., show user error, suggest alternatives) if (from.getBalance().lessThan(amount)) { throw new InsufficientFundsException(from, amount); } // ... proceed with transfer} // ===================================================// SCENARIO: Resource Cleanup Failures// RECOMMENDATION: Usually suppress or log, don't throw new// =================================================== // RATIONALE:// - Original exception (if any) is more important// - Cleanup failures are often secondary concerns// - Throwing masks the real problem public void processFile(Path path) { InputStream input = null; try { input = Files.newInputStream(path); processStream(input); } catch (IOException e) { throw new ProcessingException("Failed to process: " + path, e); } finally { if (input != null) { try { input.close(); } catch (IOException e) { // Log but don't throw - original exception is primary logger.warn("Failed to close input stream", e); } } }} // Even better: Use try-with-resourcespublic void processFileModern(Path path) { try (InputStream input = Files.newInputStream(path)) { processStream(input); } catch (IOException e) { throw new ProcessingException("Failed to process: " + path, e); } // Close exception is automatically suppressed}Well-designed exception hierarchies enable flexible catching strategies and communicate the nature of errors clearly. Poor hierarchies lead to overly broad catch blocks or excessive specific catches.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
// ===================================================// PRINCIPLE 1: Create a Module-Level Base Exception// =================================================== // Base exception for entire module/subsystem// Allows catching all module errors with one catch public abstract class PaymentException extends RuntimeException { protected PaymentException(String message) { super(message); } protected PaymentException(String message, Throwable cause) { super(message, cause); }} // ===================================================// PRINCIPLE 2: Categorize by Nature/Recoverability// =================================================== // Category: Transient errors (retryable)public abstract class TransientPaymentException extends PaymentException { private final Duration suggestedRetryDelay; protected TransientPaymentException(String message, Duration retryDelay) { super(message); this.suggestedRetryDelay = retryDelay; } public Duration getSuggestedRetryDelay() { return suggestedRetryDelay; } public boolean isRetryable() { return true; }} // Category: Permanent errors (don't retry)public abstract class PermanentPaymentException extends PaymentException { protected PermanentPaymentException(String message) { super(message); } public boolean isRetryable() { return false; }} // Category: Client errors (caller needs to fix something)public abstract class PaymentClientException extends PermanentPaymentException { protected PaymentClientException(String message) { super(message); }} // ===================================================// PRINCIPLE 3: Specific Exceptions Under Categories// =================================================== // Transient exceptionspublic class PaymentGatewayTimeoutException extends TransientPaymentException { public PaymentGatewayTimeoutException() { super("Payment gateway timed out", Duration.ofSeconds(5)); }} public class PaymentGatewayUnavailableException extends TransientPaymentException { public PaymentGatewayUnavailableException(String reason) { super("Payment gateway unavailable: " + reason, Duration.ofMinutes(1)); }} // Permanent/Client exceptions public class InvalidCardException extends PaymentClientException { private final String maskedCardNumber; public InvalidCardException(String maskedCardNumber, String reason) { super("Invalid card " + maskedCardNumber + ": " + reason); this.maskedCardNumber = maskedCardNumber; } public String getMaskedCardNumber() { return maskedCardNumber; }} public class InsufficientFundsException extends PaymentClientException { private final BigDecimal requested; private final BigDecimal available; public InsufficientFundsException(BigDecimal requested, BigDecimal available) { super("Insufficient funds: requested " + requested + ", available " + available); this.requested = requested; this.available = available; }} public class PaymentDeclinedException extends PermanentPaymentException { private final String declineCode; public PaymentDeclinedException(String declineCode, String message) { super(message); this.declineCode = declineCode; } public String getDeclineCode() { return declineCode; }} // ===================================================// USAGE: Flexible Catching at Different Levels// =================================================== public class PaymentService { public PaymentResult processPayment(PaymentRequest request) { try { return gateway.charge(request); } catch (TransientPaymentException e) { // Retry logic for all transient errors scheduleRetry(request, e.getSuggestedRetryDelay()); throw e; } catch (PaymentClientException e) { // Client errors - log and inform user logger.info("Payment client error", e); return PaymentResult.clientError(e.getMessage()); } catch (PaymentException e) { // All other payment errors logger.error("Unexpected payment error", e); throw e; } }} // ===================================================// PRINCIPLE 4: Include Contextual Information// =================================================== public class OrderProcessingException extends RuntimeException { private final String orderId; private final OrderStatus statusAtFailure; private final String operation; public OrderProcessingException( String orderId, OrderStatus statusAtFailure, String operation, String message) { super(buildMessage(orderId, operation, message)); this.orderId = orderId; this.statusAtFailure = statusAtFailure; this.operation = operation; } private static String buildMessage( String orderId, String operation, String message) { return String.format( "Failed to %s order %s: %s", operation, orderId, message); } // Getters for programmatic access public String getOrderId() { return orderId; } public OrderStatus getStatusAtFailure() { return statusAtFailure; } public String getOperation() { return operation; }}Knowing what NOT to do is as important as knowing best practices. These anti-patterns represent common mistakes that undermine error handling quality.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
// ANTI-PATTERN 1: The Swallowed Exception// ❌ DO NOT DO THIS try { processOrder(order);} catch (Exception e) { // "I'll handle this later" // NEVER: Silent failures are debugging nightmares} // ❌ ALSO BAD: Log and continue silentlytry { sendNotification(user);} catch (Exception e) { log.error("Failed", e); // Continues as if nothing happened} // ANTI-PATTERN 2: Catch-All and Wrap// ❌ Wrapping everything in RuntimeException try { doSomething();} catch (Exception e) { throw new RuntimeException(e); // Destroys exception type info!} // ANTI-PATTERN 3: Exception for Flow Control// ❌ Using exceptions for normal branching try { User user = getUser(id); // throws if not found return user.getName();} catch (UserNotFoundException e) { return "Anonymous"; // Expected case!} // ANTI-PATTERN 4: Over-Generic Exceptions// ❌ Using Exception or RuntimeException directly throw new RuntimeException("Something went wrong");// What went wrong? What can caller do? // ANTI-PATTERN 5: Exception Volcano// ❌ Every method throws everything void processOrder() throws IOException, SQLException, ParseException, ValidationException, SecurityException, TransformationException, NotificationException{ // Way too many checked exceptions // Callers have no idea what to actually handle}1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// CORRECT: Handle or propagate meaningfully// ✓ DO THIS INSTEAD try { processOrder(order);} catch (PaymentException e) { // Specific handling with fallback refundPartialPayment(order); notifyCustomerFailure(order, e); throw new OrderFailedException(order, e);} // CORRECT: Wrap in domain exception// ✓ Preserve type info try { doSomething();} catch (SQLException e) { throw new RepositoryException( "Database operation failed", e); // Specific type, original cause preserved} // CORRECT: Return type for expected cases// ✓ Use Optional or Result, not exceptions Optional<User> user = findUser(id);return user.map(User::getName) .orElse("Anonymous"); // CORRECT: Specific exception with context// ✓ Meaningful type and message throw new OrderNotFoundException(orderId);// ORthrow new InsufficientInventoryException( itemId, requested, available); // CORRECT: Aggregate into domain exception// ✓ Single coherent exception void processOrder() throws OrderProcessingException { try { // All the various operations } catch (IOException | SQLException e) { throw new OrderProcessingException( order.getId(), "processing", e); }}Empty catch blocks transform failures into silent data corruption, mysterious behavior, and debugging nightmares. If you truly can't handle an exception meaningfully, at minimum log it with full context and rethrow. Never silently swallow exceptions—it's better to crash than to corrupt data silently.
Different languages have different idioms. Here's how to apply our principles in practical language-specific contexts.
| Language | Strategy | Key Practices |
|---|---|---|
| Java | Use checked sparingly, prefer unchecked | Checked only for immediate recovery; use RuntimeException subclasses for domain exceptions; leverage try-with-resources |
| C# | All unchecked; rely on documentation | XML doc comments for exceptions; use when clause for pattern matching in catch; consider Result<T> libraries |
| Kotlin | Unchecked + Result types | Use sealed classes for domain results; kotlin.Result for simple cases; @Throws only for Java interop |
| Python | EAFP style; class-based exceptions | Define exception hierarchies under module base class; use context managers for cleanup; leverage exception chaining |
| Go | Errors as values; explicit checking | Return error as last value; use errors.Is/As for matching; wrap errors with context; panic only for truly unrecoverable |
| Rust | Result<T,E> everywhere | Define error enums; use ? for propagation; thiserror for libraries, anyhow for apps; never panic in library code |
| Swift | throws + Result for different cases | throws for operations with failure modes; Result for async/callback contexts; leverage pattern matching in catch |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// JAVA MODERN STRATEGY // 1. Base module exception (unchecked)public class OrderModuleException extends RuntimeException { /* ... */ } // 2. Specific exceptions for different failure modespublic class OrderNotFoundException extends OrderModuleException { /* ... */ }public class OrderValidationException extends OrderModuleException { /* ... */ } // 3. Use Optional for "not found" in repositoriespublic interface OrderRepository { Optional<Order> findById(String id); // Returns empty, not exception Order save(Order order); // Throws on error} // 4. Service layer converts Optional to exceptionpublic class OrderService { public Order getOrder(String id) { return repository.findById(id) .orElseThrow(() -> new OrderNotFoundException(id)); }} // 5. Controller handles at boundary@RestControllerpublic class OrderController { @GetMapping("/{id}") public ResponseEntity<Order> get(@PathVariable String id) { try { return ResponseEntity.ok(service.getOrder(id)); } catch (OrderNotFoundException e) { return ResponseEntity.notFound().build(); } }} // 6. Global exception handler for unexpected errors@ControllerAdvicepublic class GlobalHandler { @ExceptionHandler(OrderModuleException.class) public ResponseEntity<Error> handleOrderError(OrderModuleException e) { return ResponseEntity .status(HttpStatus.INTERNAL_SERVER_ERROR) .body(new Error(e.getMessage())); }}Let's walk through some realistic scenarios and trace the decision process.
Let's consolidate everything into an actionable summary and checklist you can reference in your daily work.
You've completed the Checked vs Unchecked Exceptions module. You now understand the deep trade-offs between exception types, how different languages approach error handling, and have a principled framework for making exception design decisions in your own systems. Apply these principles consistently, and your error handling will be robust, maintainable, and appropriate for each context.