Exception Hierarchy & Best Practice

บทนำ: เหตุใดต้อง “ลำดับชั้น” ของ Exceptions?

ในบทความที่แล้ว เราเรียนรู้ try-catch-finally และ custom exceptions แบบพื้นฐาน แต่ในโลก production code ที่มีความซับซ้อน เราต้องเข้าใจ:

  • ทำไม Java ออกแบบ exception เป็นลำดับชั้น?
  • ปัญหาอะไรที่เกิดถ้าจัดการ exception ผิด?
  • Best practices อะไรที่ professionals ใช้?
  • เมื่อไหร่ต้องสร้าง custom exception เอง?

Exception Hierarchy & Best Practice = การออกแบบ exception handling ให้ ปลอดภัย, ชัดเจน, และ maintainable


Exception Hierarchy ใน Java: เข้าใจลึกขึ้น

ลำดับชั้นแบบคร่าว

textThrowable (superclass ของ exceptions ทั้งหมด)
├─ Error (ร้ายแรง, ไม่ควร catch)
│  ├─ OutOfMemoryError
│  ├─ StackOverflowError
│  ├─ VirtualMachineError
│  └─ ...
│
└─ Exception (ปกติ, ควร catch/handle)
   ├─ Checked Exception (บังคับต้อง handle)
   │  ├─ IOException
   │  │  ├─ FileNotFoundException
   │  │  ├─ SocketException
   │  │  └─ ...
   │  ├─ SQLException
   │  ├─ ClassNotFoundException
   │  └─ ...
   │
   └─ Unchecked Exception (ไม่บังคับแต่ควร)
      ├─ RuntimeException
      │  ├─ NullPointerException
      │  ├─ ArrayIndexOutOfBoundsException
      │  ├─ ArithmeticException
      │  ├─ IllegalArgumentException
      │  ├─ ClassCastException
      │  └─ ...
      └─ ...

ความแตกต่างที่สำคัญ

CheckedUnchecked
Inheritanceextends Exceptionextends RuntimeException
Compile CheckCompiler บังคับต้อง handleCompiler ไม่บังคับ
RuntimeI/O, Database, NetworkLogic errors, bad data
ตัวอย่างIOException, SQLExceptionNullPointerException, IllegalArgumentException
ควรเมื่อRecoverable situationProgramming error

ตัวอย่างที่ 1: Exception Hierarchy ในการใช้งาน

❌ ไม่ดี: เข้าใจผิดเกี่ยวกับ Exception

java// ❌ MISTAKE 1: Catching general Exception
try {
    // complex operation
    int result = calculateTotal(items);
    saveToDatabase(result);
} catch (Exception e) {  // ← Too broad!
    System.out.println("Error: " + e.getMessage());
}
// ปัญหา: ถ้า NullPointerException เกิด มันจะถูก catch
// ซึ่งอาจเป็น bug ที่ไม่ควรถูก suppress

// ❌ MISTAKE 2: Catching and ignoring
try {
    connectToDatabase();
} catch (SQLException e) {
    // ← empty! ไม่ทำอะไรเลย
}
// ปัญหา: Database connection ล้มเหลว แต่ code ยังทำงานต่อ
// เหมือน "ปิดหู" ละเลยปัญหา

// ❌ MISTAKE 3: Catching Checked Exception ไม่จำเป็น
try {
    String data = "12345";
    int number = Integer.parseInt(data);  // ← Unchecked exception
} catch (NumberFormatException e) {  // ← ไม่จำเป็น
    System.out.println("Invalid number");
}
// ไม่ผิด แต่ Unchecked exceptions มักมาจาก bugs
// ดีกว่า validate ข้อมูลก่อนแทน

// ❌ MISTAKE 4: Creating exception ใหม่โดยไม่ต้อง
public void processOrder() throws Exception {  // ← Generic
    // ← ไม่รู้ว่า Exception ไหน
}
// ปัญหา: Caller ไม่รู้ว่าต้อง handle อะไร

✓ ดี: Best Practices

java// ✓ PRACTICE 1: Catch specific exceptions
try {
    saveToDatabase(data);
} catch (SQLException e) {
    // Handle database error specifically
    System.err.println("Database error: " + e.getMessage());
    notifyDatabaseTeam(e);
} catch (IOException e) {
    // Handle I/O error
    System.err.println("File I/O error: " + e.getMessage());
    retryOperation();
}
// ดี: แต่ละ exception ได้ handling ที่เหมาะสม

// ✓ PRACTICE 2: Let exceptions propagate if not recoverable
public void criticalOperation() throws SQLException {
    // If we can't handle it, let caller handle
    // ← อย่า catch ถ้าไม่มี recovery strategy
    executeSQL("DELETE FROM users");
}

// ✓ PRACTICE 3: Validate data to prevent unchecked exceptions
String input = getUserInput();
if (input == null || input.isEmpty()) {
    throw new IllegalArgumentException("Input cannot be empty");
}
// ดีกว่า รอ NullPointerException เกิดแล้ว catch

// ✓ PRACTICE 4: Throw specific custom exceptions
public void createUser(String email) throws InvalidEmailException {
    if (!isValidEmail(email)) {
        throw new InvalidEmailException(
            "Email format invalid: " + email
        );
    }
    // ← Caller รู้ว่า method นี้อาจ throw InvalidEmailException
}

// ✓ PRACTICE 5: Preserve the cause chain
try {
    connectToDatabase();
} catch (SQLException e) {
    // Don't lose original exception!
    throw new DataAccessException(
        "Failed to access database",
        e  // ← chain the cause
    );
}

ตัวอย่างที่ 2: Designing Custom Exception Hierarchy

ปัญหา: เมื่อไหร่ต้องสร้าง Custom Exception?

textใช้ custom exception เมื่อ:
1. Exception นี้เป็น business logic (ไม่ใช่ technical)
2. Caller ต้องรู้ว่าข้อผิดพลาด business นี้เกิดขึ้น
3. ต้องมี specific handling สำหรับสถานการณ์นี้

ไม่ต้อง custom exception เมื่อ:
- อาจใช้ built-in exception ได้
- Exception นี้ไม่ได้เป็น business requirement

ตัวอย่าง: Banking System Exception Hierarchy

java// ==== Root Custom Exception ====
public abstract class BankingException extends Exception {
    private String errorCode;
    
    public BankingException(String message, String errorCode) {
        super(message);
        this.errorCode = errorCode;
    }
    
    public String getErrorCode() {
        return errorCode;
    }
}

// ==== Checked Custom Exceptions (Business errors) ====

// 1. Account-related errors
public class AccountNotFoundException extends BankingException {
    public AccountNotFoundException(String accountNumber) {
        super(
            "Account not found: " + accountNumber,
            "ACCOUNT_NOT_FOUND"
        );
    }
}

public class AccountFrozenException extends BankingException {
    public AccountFrozenException(String reason) {
        super(
            "Account is frozen: " + reason,
            "ACCOUNT_FROZEN"
        );
    }
}

// 2. Transaction-related errors
public class InsufficientBalanceException extends BankingException {
    private double requested;
    private double available;
    
    public InsufficientBalanceException(
        double requested, 
        double available
    ) {
        super(
            String.format(
                "Insufficient balance. Requested: %.2f, Available: %.2f",
                requested, available
            ),
            "INSUFFICIENT_BALANCE"
        );
        this.requested = requested;
        this.available = available;
    }
    
    public double getShortage() {
        return requested - available;
    }
}

public class DailyLimitExceededException extends BankingException {
    public DailyLimitExceededException(double limit, double attempted) {
        super(
            String.format(
                "Daily limit exceeded. Limit: %.2f, Attempted: %.2f",
                limit, attempted
            ),
            "DAILY_LIMIT_EXCEEDED"
        );
    }
}

// 3. Security-related errors
public class InvalidPINException extends BankingException {
    private int attemptsLeft;
    
    public InvalidPINException(int attemptsLeft) {
        super(
            String.format(
                "Invalid PIN. Attempts left: %d",
                attemptsLeft
            ),
            "INVALID_PIN"
        );
        this.attemptsLeft = attemptsLeft;
    }
    
    public int getAttemptsLeft() {
        return attemptsLeft;
    }
}

// ==== Unchecked Custom Exceptions (Programming errors) ====

public class InvalidTransactionException extends RuntimeException {
    public InvalidTransactionException(String reason) {
        super("Invalid transaction: " + reason);
    }
}

public class InvalidAccountStateException extends RuntimeException {
    public InvalidAccountStateException(String currentState) {
        super("Invalid account state: " + currentState);
    }
}

การใช้ Exception Hierarchy

javapublic class BankAccount {
    private double balance;
    private boolean frozen;
    private double dailyLimit;
    private double dailyWithdrawn;
    
    // ==== Checked Exception (must be caught) ====
    public void withdraw(double amount) 
        throws AccountFrozenException,
               InsufficientBalanceException,
               DailyLimitExceededException {
        
        // Business validation
        if (frozen) {
            throw new AccountFrozenException("Suspect fraud");
        }
        
        if (amount > balance) {
            throw new InsufficientBalanceException(amount, balance);
        }
        
        if (dailyWithdrawn + amount > dailyLimit) {
            throw new DailyLimitExceededException(
                dailyLimit, 
                dailyWithdrawn + amount
            );
        }
        
        // Safe to proceed
        balance -= amount;
        dailyWithdrawn += amount;
    }
    
    // ==== Unchecked Exception (programming error) ====
    public void setDailyLimit(double limit) {
        if (limit < 0) {
            throw new InvalidTransactionException(
                "Limit cannot be negative"
            );
        }
        this.dailyLimit = limit;
    }
}

// ==== Client Code: Specific Handling ====
public class ATMService {
    public void processWithdrawal(String accountNumber, double amount) {
        try {
            BankAccount account = getAccount(accountNumber);
            account.withdraw(amount);
            System.out.println("✓ Withdrawal successful");
            
        } catch (AccountFrozenException e) {
            // ← Business logic: Account is frozen
            System.err.println("❌ " + e.getMessage());
            contactFraudTeam(accountNumber);
            
        } catch (InsufficientBalanceException e) {
            // ← Business logic: Not enough money
            System.err.println("❌ " + e.getMessage());
            System.out.println("   Shortage: $" + e.getShortage());
            offerLoanOption();
            
        } catch (DailyLimitExceededException e) {
            // ← Business logic: Daily limit exceeded
            System.err.println("❌ " + e.getMessage());
            System.out.println("   Try again tomorrow");
            
        } catch (BankingException e) {
            // ← Catch-all for any other banking exception
            System.err.println("❌ Banking error: " + e.getErrorCode());
            System.err.println("   " + e.getMessage());
            notifyBankingTeam(e);
            
        } catch (RuntimeException e) {
            // ← Programming error (shouldn't happen)
            System.err.println("❌ CRITICAL ERROR: " + e.getMessage());
            e.printStackTrace();
            shutdownATM();
        }
    }
}

ตัวอย่างที่ 3: Exception Translation & Wrapping

ปัญหา: Multiple Layers ของ Exception

textLayer 1: Database Layer
  └─ throws SQLException

Layer 2: Business Logic Layer
  └─ should throw business exception
  
Layer 3: API Layer
  └─ should return appropriate HTTP status

ปัญหา: SQLException ขึ้นมา ไปถึง API client
แต่ client ไม่รู้ว่าจะทำอะไร

วิธีแก้: Exception Translation

java// ==== Layer 1: Database Layer ====
public class UserRepository {
    public User getUserById(int id) throws SQLException {
        // Direct database operation
        // Throws SQLException if database fails
        String sql = "SELECT * FROM users WHERE id = ?";
        // ... execute query
        // SQLException อาจเกิด here
    }
}

// ==== Layer 2: Business Logic Layer ====
public class UserService {
    private UserRepository userRepository;
    
    // Translate SQLException → Business Exception
    public User getUser(int id) throws UserNotFoundException {
        try {
            User user = userRepository.getUserById(id);
            if (user == null) {
                throw new UserNotFoundException(
                    "User with ID " + id + " not found"
                );
            }
            return user;
            
        } catch (SQLException e) {
            // ← Translate technical exception to business exception
            throw new UserNotFoundException(
                "Failed to retrieve user",
                e  // ← preserve the cause
            );
        }
    }
}

// ==== Layer 3: API Layer ====
@RestController
public class UserAPI {
    private UserService userService;
    
    @GetMapping("/users/{id}")
    public ResponseEntity<UserDTO> getUser(@PathVariable int id) {
        try {
            User user = userService.getUser(id);
            return ResponseEntity.ok(mapToDTO(user));
            
        } catch (UserNotFoundException e) {
            // ← Handle business exception, return 404
            return ResponseEntity.notFound().build();
            
        } catch (Exception e) {
            // ← Unexpected error, return 500
            System.err.println("Unexpected error: " + e.getMessage());
            return ResponseEntity.status(500).build();
        }
    }
}

ตัวอย่างที่ 4: Common Patterns & Anti-Patterns

Pattern 1: Fail-Fast Validation

java// ✓ GOOD: Validate early and throw specific exceptions
public class OrderService {
    public Order placeOrder(OrderRequest request) 
        throws InvalidOrderException {
        
        // Validate input immediately
        if (request == null) {
            throw new IllegalArgumentException("OrderRequest cannot be null");
        }
        
        if (request.getItems().isEmpty()) {
            throw new InvalidOrderException("Order must have at least one item");
        }
        
        if (request.getCustomerId() <= 0) {
            throw new InvalidOrderException("Invalid customer ID");
        }
        
        if (request.getTotalAmount() < 0) {
            throw new InvalidOrderException("Total amount cannot be negative");
        }
        
        // Now we know input is valid, proceed
        return createOrder(request);
    }
}

// ❌ BAD: Trying to catch NullPointerException instead of validating
public class OrderService {
    public Order placeOrder(OrderRequest request) {
        try {
            request.getItems().stream()  // ← NPE here if request is null
                .forEach(item -> process(item));
            return new Order();
        } catch (NullPointerException e) {
            // ← This is bad practice!
            return null;
        }
    }
}

Pattern 2: Wrapping with Context

java// ✓ GOOD: Add context to exception
public void processFile(String filename) throws FileProcessingException {
    try {
        File file = new File(filename);
        if (!file.exists()) {
            throw new FileNotFoundException(filename);
        }
        readAndProcess(file);
        
    } catch (FileNotFoundException e) {
        throw new FileProcessingException(
            "Could not find file: " + filename,
            e
        );
    } catch (IOException e) {
        throw new FileProcessingException(
            "Error processing file: " + filename,
            e
        );
    }
}

// ❌ BAD: Lose the original exception context
public void processFile(String filename) throws FileProcessingException {
    try {
        readAndProcess(new File(filename));
    } catch (Exception e) {
        throw new FileProcessingException("File processing failed");
        // ← Original exception lost! ไม่รู้ว่ามาจากไหน
    }
}

Pattern 3: Resource Management

java// ✓ GOOD: Try-with-resources (auto-close)
public void readConfiguration(String filename) 
    throws IOException {
    try (FileReader reader = new FileReader(filename)) {
        // reader will be auto-closed
        String config = readContent(reader);
        parseConfiguration(config);
    }
    // reader auto-closed here, even if exception occurs
}

// ❌ BAD: Manual resource management (easy to forget)
public void readConfiguration(String filename) 
    throws IOException {
    FileReader reader = new FileReader(filename);
    try {
        String config = readContent(reader);
        parseConfiguration(config);
    } finally {
        reader.close();  // ← Easy to forget or mess up
    }
}

ตัวอย่างที่ 5: Real-World Scenario

E-Commerce Order Processing with Exception Hierarchy

java// ==== Custom Exception Hierarchy ====
public abstract class OrderException extends Exception {
    public OrderException(String message) {
        super(message);
    }
    public OrderException(String message, Throwable cause) {
        super(message, cause);
    }
}

public class InsufficientInventoryException extends OrderException {
    private String productId;
    private int requested;
    private int available;
    
    public InsufficientInventoryException(
        String productId, int requested, int available
    ) {
        super(
            String.format(
                "Product %s: requested %d, available %d",
                productId, requested, available
            )
        );
        this.productId = productId;
        this.requested = requested;
        this.available = available;
    }
}

public class PaymentFailedException extends OrderException {
    public enum Reason {
        CARD_DECLINED("Card was declined"),
        INSUFFICIENT_FUNDS("Insufficient funds"),
        TIMEOUT("Payment gateway timeout"),
        FRAUD_DETECTED("Fraud detected");
        
        public final String description;
        Reason(String description) {
            this.description = description;
        }
    }
    
    private Reason reason;
    
    public PaymentFailedException(Reason reason) {
        super("Payment failed: " + reason.description);
        this.reason = reason;
    }
}

// ==== Service Layer ====
public class OrderService {
    private InventoryService inventoryService;
    private PaymentService paymentService;
    
    public Order createOrder(OrderRequest request) 
        throws InsufficientInventoryException,
               PaymentFailedException {
        
        // Step 1: Validate and reserve inventory
        for (OrderItem item : request.getItems()) {
            try {
                inventoryService.reserve(
                    item.getProductId(),
                    item.getQuantity()
                );
            } catch (InventoryException e) {
                // Translate to business exception
                throw new InsufficientInventoryException(
                    item.getProductId(),
                    item.getQuantity(),
                    e.getAvailable()
                );
            }
        }
        
        // Step 2: Process payment
        try {
            paymentService.charge(
                request.getPaymentMethod(),
                request.getTotalAmount()
            );
        } catch (PaymentGatewayException e) {
            // Translate to business exception
            if (e.getErrorCode().equals("DECLINED")) {
                throw new PaymentFailedException(
                    PaymentFailedException.Reason.CARD_DECLINED
                );
            } else if (e.getErrorCode().equals("TIMEOUT")) {
                throw new PaymentFailedException(
                    PaymentFailedException.Reason.TIMEOUT
                );
            }
            // ... handle other cases
            
            throw new PaymentFailedException(
                PaymentFailedException.Reason.CARD_DECLINED
            );
        }
        
        // Step 3: Create order (if we reach here, all is well)
        Order order = new Order(request);
        saveOrder(order);
        return order;
    }
}

// ==== API Layer ====
@RestController
public class OrderAPI {
    private OrderService orderService;
    
    @PostMapping("/orders")
    public ResponseEntity<?> createOrder(@RequestBody OrderRequest request) {
        try {
            Order order = orderService.createOrder(request);
            return ResponseEntity.ok(mapToDTO(order));
            
        } catch (InsufficientInventoryException e) {
            // Business error: insufficient inventory
            Map<String, Object> response = new HashMap<>();
            response.put("error", "INSUFFICIENT_INVENTORY");
            response.put("message", e.getMessage());
            return ResponseEntity.status(400).body(response);
            
        } catch (PaymentFailedException e) {
            // Business error: payment failed
            Map<String, Object> response = new HashMap<>();
            response.put("error", "PAYMENT_FAILED");
            response.put("message", e.getMessage());
            return ResponseEntity.status(402).body(response);
            // ← HTTP 402 Payment Required
            
        } catch (OrderException e) {
            // Other order-related errors
            System.err.println("Order error: " + e.getMessage());
            return ResponseEntity.status(400)
                .body(Map.of("error", e.getMessage()));
                
        } catch (Exception e) {
            // Unexpected error
            System.err.println("CRITICAL: " + e.getMessage());
            e.printStackTrace();
            return ResponseEntity.status(500)
                .body(Map.of("error", "Internal server error"));
        }
    }
}

Best Practices Checklist

text✓ DO:
  ☑ Use specific exception types
  ☑ Preserve exception cause chain
  ☑ Validate input early (fail-fast)
  ☑ Provide context in exception message
  ☑ Use custom exceptions for business errors
  ☑ Let unchecked exceptions propagate (if it's a bug)
  ☑ Use try-with-resources for resource management
  ☑ Handle exceptions at appropriate layer

✗ DON'T:
  ☐ Catch generic Exception
  ☐ Catch and ignore silently
  ☐ Lose original exception cause
  ☐ Catch unchecked exceptions (catch logic errors)
  ☐ Throw generic Exception
  ☐ Use exceptions for control flow
  ☐ Create exception per error message
  ☐ Catch Exception, then throw new Exception()

สรุป

Exception Hierarchy & Best Practice ไม่ได้เป็นแค่การ “catch errors” แต่เป็นการออกแบบ robust error handling:

ทำไมต้องลำดับชั้น:

  • Checked vs Unchecked ให้เรารู้ว่าควร handle ตรงไหน
  • Custom exceptions ให้เรารู้ว่า error นี้มาจากธุรกิจ หรือ technical
  • Exception chains ให้เราจำการ “เส้นสำยของสาเหตุ”

Best Practices ที่ดี:

  • Specific – catch ที่พอดี ไม่กว้างเกิน
  • Context – exception message ต้องมีข้อมูลเพียงพอ
  • Chainable – preserve original exception
  • Translatable – แปล technical exception เป็น business exception

เมื่อใช้ Exception Hierarchy อย่างถูกต้อง code ของเรา จะ:

  • เข้าใจง่าย – รู้ว่า error มาจากไหน
  • ปลอดภัยกว่า – bug ทำให้ crash ไม่ได้ suppress
  • ดูแลรักษาง่าย – ปัญหาจดจำได้ง่าย และแก้ได้เร็ว
  • Professional – ทำให้ดูเหมือน production code จริง

Exception handling ที่ดีคือ แลกหัวใจเพื่อความเชื่อถือได้ของระบบ